simple-module-settings 0.0.1__py3-none-any.whl
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.
- settings/__init__.py +1 -0
- settings/_module_settings.py +169 -0
- settings/cli.py +78 -0
- settings/constants.py +115 -0
- settings/contracts/__init__.py +24 -0
- settings/contracts/accessor.py +236 -0
- settings/contracts/events.py +20 -0
- settings/contracts/registry.py +66 -0
- settings/contracts/schemas.py +154 -0
- settings/deps.py +56 -0
- settings/endpoints/__init__.py +0 -0
- settings/endpoints/api.py +202 -0
- settings/endpoints/module_api.py +102 -0
- settings/endpoints/views.py +120 -0
- settings/env_vars.py +18 -0
- settings/hydrate.py +63 -0
- settings/locales/en.json +71 -0
- settings/models.py +50 -0
- settings/module.py +76 -0
- settings/module_registry.py +33 -0
- settings/package.json +16 -0
- settings/pages/Browse.tsx +103 -0
- settings/pages/Create.tsx +113 -0
- settings/pages/Edit.tsx +102 -0
- settings/pages/ModulesEdit.tsx +74 -0
- settings/pages/components/FieldInput.tsx +96 -0
- settings/pages/components/ModuleForm.tsx +157 -0
- settings/pages/components/ValueInput.tsx +92 -0
- settings/pages/routes.ts +7 -0
- settings/py.typed +0 -0
- settings/registration.py +34 -0
- settings/reload.py +58 -0
- settings/service.py +162 -0
- settings/services.py +36 -0
- settings/settings.py +11 -0
- settings/store.py +61 -0
- simple_module_settings-0.0.1.dist-info/METADATA +69 -0
- simple_module_settings-0.0.1.dist-info/RECORD +41 -0
- simple_module_settings-0.0.1.dist-info/WHEEL +4 -0
- simple_module_settings-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_settings-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,66 @@
|
|
|
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 dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
from settings.constants import ERR_KEY_ALREADY_EXISTS
|
|
31
|
+
from settings.contracts.schemas import SettingScope, SettingValueType
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SettingDefinition:
|
|
36
|
+
"""Declared metadata for a setting key."""
|
|
37
|
+
|
|
38
|
+
key: str
|
|
39
|
+
default: str = ""
|
|
40
|
+
description: str = ""
|
|
41
|
+
scope: SettingScope = SettingScope.SYSTEM
|
|
42
|
+
value_type: SettingValueType = SettingValueType.STRING
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class SettingsRegistry:
|
|
47
|
+
"""In-memory registry of declared setting keys. Populated at module boot
|
|
48
|
+
so admins (and other modules) can discover every knob the app exposes.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_defs: dict[str, SettingDefinition] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def add(self, definition: SettingDefinition) -> None:
|
|
54
|
+
if definition.key in self._defs:
|
|
55
|
+
raise ValueError(f"{ERR_KEY_ALREADY_EXISTS}: {definition.key!r}")
|
|
56
|
+
self._defs[definition.key] = definition
|
|
57
|
+
|
|
58
|
+
def get(self, key: str) -> SettingDefinition | None:
|
|
59
|
+
return self._defs.get(key)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def all_definitions(self) -> list[SettingDefinition]:
|
|
63
|
+
return list(self._defs.values())
|
|
64
|
+
|
|
65
|
+
def __contains__(self, key: str) -> bool:
|
|
66
|
+
return key in self._defs
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""SQLModel DTOs + SettingScope / SettingValueType enums."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from pydantic import ConfigDict, model_validator
|
|
10
|
+
from sqlmodel import Field, SQLModel
|
|
11
|
+
|
|
12
|
+
from settings.constants import (
|
|
13
|
+
DESCRIPTION_MAX_LENGTH,
|
|
14
|
+
ERR_SCOPED_REQUIRES_ID,
|
|
15
|
+
ERR_SYSTEM_SCOPE_NO_ID,
|
|
16
|
+
ERR_VALUE_MISMATCH,
|
|
17
|
+
KEY_MAX_LENGTH,
|
|
18
|
+
SCOPE_ID_MAX_LENGTH,
|
|
19
|
+
SCOPE_SYSTEM,
|
|
20
|
+
SCOPE_TENANT,
|
|
21
|
+
SCOPE_USER,
|
|
22
|
+
SYSTEM_SCOPE_ID,
|
|
23
|
+
VALUE_MAX_LENGTH,
|
|
24
|
+
VALUE_TYPE_BOOL,
|
|
25
|
+
VALUE_TYPE_FLOAT,
|
|
26
|
+
VALUE_TYPE_INT,
|
|
27
|
+
VALUE_TYPE_JSON,
|
|
28
|
+
VALUE_TYPE_STRING,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_BOOL_LITERALS = frozenset(
|
|
32
|
+
{"true", "false", "1", "0", "t", "f", "yes", "no", "y", "n", "on", "off"}
|
|
33
|
+
)
|
|
34
|
+
BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
35
|
+
BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SettingScope(StrEnum):
|
|
39
|
+
"""Override level for a setting entry."""
|
|
40
|
+
|
|
41
|
+
SYSTEM = SCOPE_SYSTEM
|
|
42
|
+
TENANT = SCOPE_TENANT
|
|
43
|
+
USER = SCOPE_USER
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SettingValueType(StrEnum):
|
|
47
|
+
"""How the stored ``value`` string should be interpreted."""
|
|
48
|
+
|
|
49
|
+
STRING = VALUE_TYPE_STRING
|
|
50
|
+
BOOL = VALUE_TYPE_BOOL
|
|
51
|
+
INT = VALUE_TYPE_INT
|
|
52
|
+
FLOAT = VALUE_TYPE_FLOAT
|
|
53
|
+
JSON = VALUE_TYPE_JSON
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_scope_id(scope: SettingScope, scope_id: str) -> None:
|
|
57
|
+
if scope is SettingScope.SYSTEM and scope_id:
|
|
58
|
+
raise ValueError(ERR_SYSTEM_SCOPE_NO_ID)
|
|
59
|
+
if scope is not SettingScope.SYSTEM and not scope_id:
|
|
60
|
+
raise ValueError(ERR_SCOPED_REQUIRES_ID)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_value_matches_type(value: str, value_type: SettingValueType) -> None:
|
|
64
|
+
"""Ensure ``value`` parses as the declared type. Empty values are always ok."""
|
|
65
|
+
if value == "":
|
|
66
|
+
return
|
|
67
|
+
if value_type is SettingValueType.STRING:
|
|
68
|
+
return
|
|
69
|
+
if value_type is SettingValueType.BOOL:
|
|
70
|
+
if value.strip().lower() not in _BOOL_LITERALS:
|
|
71
|
+
raise ValueError(ERR_VALUE_MISMATCH)
|
|
72
|
+
return
|
|
73
|
+
if value_type is SettingValueType.INT:
|
|
74
|
+
try:
|
|
75
|
+
int(value)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise ValueError(ERR_VALUE_MISMATCH) from exc
|
|
78
|
+
return
|
|
79
|
+
if value_type is SettingValueType.FLOAT:
|
|
80
|
+
try:
|
|
81
|
+
float(value)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
raise ValueError(ERR_VALUE_MISMATCH) from exc
|
|
84
|
+
return
|
|
85
|
+
if value_type is SettingValueType.JSON:
|
|
86
|
+
try:
|
|
87
|
+
json.loads(value)
|
|
88
|
+
except (TypeError, ValueError) as exc:
|
|
89
|
+
raise ValueError(ERR_VALUE_MISMATCH) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SettingOut(SQLModel):
|
|
93
|
+
"""A setting returned by the API."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(from_attributes=True)
|
|
96
|
+
|
|
97
|
+
id: int
|
|
98
|
+
scope: SettingScope
|
|
99
|
+
scope_id: str
|
|
100
|
+
key: str
|
|
101
|
+
value: str
|
|
102
|
+
value_type: SettingValueType = SettingValueType.STRING
|
|
103
|
+
description: str | None = None
|
|
104
|
+
created_at: datetime | None = None
|
|
105
|
+
updated_at: datetime | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SettingCreate(SQLModel):
|
|
109
|
+
"""Payload to create a new setting at an explicit scope."""
|
|
110
|
+
|
|
111
|
+
scope: SettingScope = SettingScope.SYSTEM
|
|
112
|
+
scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH)
|
|
113
|
+
key: str = Field(min_length=1, max_length=KEY_MAX_LENGTH)
|
|
114
|
+
value: str = Field(max_length=VALUE_MAX_LENGTH)
|
|
115
|
+
value_type: SettingValueType = SettingValueType.STRING
|
|
116
|
+
description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH)
|
|
117
|
+
|
|
118
|
+
@model_validator(mode="after")
|
|
119
|
+
def _check(self) -> SettingCreate:
|
|
120
|
+
_validate_scope_id(self.scope, self.scope_id)
|
|
121
|
+
_validate_value_matches_type(self.value, self.value_type)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SettingUpdate(SQLModel):
|
|
126
|
+
"""Payload to update an existing setting by id. Scope cannot change."""
|
|
127
|
+
|
|
128
|
+
value: str | None = Field(default=None, max_length=VALUE_MAX_LENGTH)
|
|
129
|
+
value_type: SettingValueType | None = None
|
|
130
|
+
description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH)
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def _check(self) -> SettingUpdate:
|
|
134
|
+
if self.value is not None and self.value_type is not None:
|
|
135
|
+
_validate_value_matches_type(self.value, self.value_type)
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SettingUpsert(SQLModel):
|
|
140
|
+
"""Payload for upsert operations (value + optional type + description).
|
|
141
|
+
|
|
142
|
+
``value_type`` is optional: when absent on an update it preserves the
|
|
143
|
+
existing row's type, and on a create it defaults to ``STRING``.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
value: str = Field(max_length=VALUE_MAX_LENGTH)
|
|
147
|
+
value_type: SettingValueType | None = None
|
|
148
|
+
description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH)
|
|
149
|
+
|
|
150
|
+
@model_validator(mode="after")
|
|
151
|
+
def _check(self) -> SettingUpsert:
|
|
152
|
+
if self.value_type is not None:
|
|
153
|
+
_validate_value_matches_type(self.value, self.value_type)
|
|
154
|
+
return self
|
settings/deps.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""FastAPI dependencies for the Settings module.
|
|
2
|
+
|
|
3
|
+
Consumers in other modules should almost always depend on ``SettingsDep``
|
|
4
|
+
(the accessor) rather than ``SettingService`` directly — the accessor is
|
|
5
|
+
bound to the current request's user/tenant so ``get_bool(key)`` etc. just
|
|
6
|
+
work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, Request
|
|
14
|
+
from simple_module_db.deps import get_db
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from settings.constants import MODULE_PACKAGE
|
|
18
|
+
from settings.contracts.accessor import SettingsAccessor
|
|
19
|
+
from settings.contracts.registry import SettingsRegistry
|
|
20
|
+
from settings.service import SettingService
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_setting_service(
|
|
24
|
+
db: AsyncSession = Depends(get_db),
|
|
25
|
+
) -> SettingService:
|
|
26
|
+
return SettingService(db)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_settings_registry(request: Request) -> SettingsRegistry:
|
|
30
|
+
"""Return the app-wide settings registry populated during boot."""
|
|
31
|
+
services = getattr(request.app.state, MODULE_PACKAGE)
|
|
32
|
+
return services.registry
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def get_settings_accessor(
|
|
36
|
+
request: Request,
|
|
37
|
+
service: SettingService = Depends(get_setting_service),
|
|
38
|
+
registry: SettingsRegistry = Depends(get_settings_registry),
|
|
39
|
+
) -> SettingsAccessor:
|
|
40
|
+
"""Build a request-scoped accessor bound to the caller's user/tenant.
|
|
41
|
+
|
|
42
|
+
``request.state.user`` is populated by ``users.middleware`` (carries a
|
|
43
|
+
``UserContext``). ``request.state.tenant_id`` is populated by
|
|
44
|
+
``TenantMiddleware`` when ``multi_tenant=True``. Both are optional —
|
|
45
|
+
the accessor gracefully degrades to lower scopes (or the registered
|
|
46
|
+
default) if the request is unauthenticated or untenanted.
|
|
47
|
+
"""
|
|
48
|
+
user = getattr(request.state, "user", None)
|
|
49
|
+
user_id = str(user.id) if user is not None else None
|
|
50
|
+
tenant_id = getattr(request.state, "tenant_id", None)
|
|
51
|
+
return SettingsAccessor(service, registry, user_id=user_id, tenant_id=tenant_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Convenience aliases for typing annotations in consumer modules.
|
|
55
|
+
SettingServiceDep = Annotated[SettingService, Depends(get_setting_service)]
|
|
56
|
+
SettingsDep = Annotated[SettingsAccessor, Depends(get_settings_accessor)]
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""REST API endpoints for the Settings module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
6
|
+
|
|
7
|
+
from settings.constants import (
|
|
8
|
+
API_BY_ID_PATH,
|
|
9
|
+
API_RESOLVE_PATH,
|
|
10
|
+
API_SYSTEM_PATH,
|
|
11
|
+
API_TENANT_PATH,
|
|
12
|
+
API_USER_PATH,
|
|
13
|
+
ERR_SETTING_NOT_FOUND,
|
|
14
|
+
QP_SCOPE,
|
|
15
|
+
QP_SCOPE_ID,
|
|
16
|
+
QP_TENANT_ID,
|
|
17
|
+
QP_USER_ID,
|
|
18
|
+
STATUS_CREATED,
|
|
19
|
+
STATUS_NO_CONTENT,
|
|
20
|
+
STATUS_NOT_FOUND,
|
|
21
|
+
SYSTEM_SCOPE_ID,
|
|
22
|
+
)
|
|
23
|
+
from settings.contracts.schemas import (
|
|
24
|
+
SettingCreate,
|
|
25
|
+
SettingOut,
|
|
26
|
+
SettingScope,
|
|
27
|
+
SettingUpdate,
|
|
28
|
+
SettingUpsert,
|
|
29
|
+
)
|
|
30
|
+
from settings.deps import get_setting_service
|
|
31
|
+
from settings.service import SettingService
|
|
32
|
+
|
|
33
|
+
router = APIRouter()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _not_found() -> HTTPException:
|
|
37
|
+
return HTTPException(status_code=STATUS_NOT_FOUND, detail=ERR_SETTING_NOT_FOUND)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── List / filter ───────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/", response_model=list[SettingOut])
|
|
44
|
+
async def list_settings(
|
|
45
|
+
scope: SettingScope | None = Query(default=None, alias=QP_SCOPE),
|
|
46
|
+
scope_id: str = Query(default=SYSTEM_SCOPE_ID, alias=QP_SCOPE_ID),
|
|
47
|
+
service: SettingService = Depends(get_setting_service),
|
|
48
|
+
) -> list[SettingOut]:
|
|
49
|
+
if scope is None:
|
|
50
|
+
return await service.list_all()
|
|
51
|
+
return await service.list_by_scope(scope, scope_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Resolution (USER > TENANT > SYSTEM) ─────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get(API_RESOLVE_PATH, response_model=SettingOut)
|
|
58
|
+
async def resolve_setting(
|
|
59
|
+
key: str,
|
|
60
|
+
user_id: str | None = Query(default=None, alias=QP_USER_ID),
|
|
61
|
+
tenant_id: str | None = Query(default=None, alias=QP_TENANT_ID),
|
|
62
|
+
service: SettingService = Depends(get_setting_service),
|
|
63
|
+
) -> SettingOut:
|
|
64
|
+
result = await service.resolve(key, user_id=user_id, tenant_id=tenant_id)
|
|
65
|
+
if result is None:
|
|
66
|
+
raise _not_found()
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Scoped (system / tenant / user) ─────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get(API_SYSTEM_PATH, response_model=SettingOut)
|
|
74
|
+
async def get_system_setting(
|
|
75
|
+
key: str, service: SettingService = Depends(get_setting_service)
|
|
76
|
+
) -> SettingOut:
|
|
77
|
+
result = await service.get_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key)
|
|
78
|
+
if result is None:
|
|
79
|
+
raise _not_found()
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.put(API_SYSTEM_PATH, response_model=SettingOut)
|
|
84
|
+
async def upsert_system_setting(
|
|
85
|
+
key: str,
|
|
86
|
+
data: SettingUpsert,
|
|
87
|
+
service: SettingService = Depends(get_setting_service),
|
|
88
|
+
) -> SettingOut:
|
|
89
|
+
return await service.upsert_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key, data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.delete(API_SYSTEM_PATH, status_code=STATUS_NO_CONTENT)
|
|
93
|
+
async def delete_system_setting(
|
|
94
|
+
key: str, service: SettingService = Depends(get_setting_service)
|
|
95
|
+
) -> None:
|
|
96
|
+
if not await service.delete_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key):
|
|
97
|
+
raise _not_found()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.get(API_TENANT_PATH, response_model=SettingOut)
|
|
101
|
+
async def get_tenant_setting(
|
|
102
|
+
scope_id: str,
|
|
103
|
+
key: str,
|
|
104
|
+
service: SettingService = Depends(get_setting_service),
|
|
105
|
+
) -> SettingOut:
|
|
106
|
+
result = await service.get_scoped(SettingScope.TENANT, scope_id, key)
|
|
107
|
+
if result is None:
|
|
108
|
+
raise _not_found()
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.put(API_TENANT_PATH, response_model=SettingOut)
|
|
113
|
+
async def upsert_tenant_setting(
|
|
114
|
+
scope_id: str,
|
|
115
|
+
key: str,
|
|
116
|
+
data: SettingUpsert,
|
|
117
|
+
service: SettingService = Depends(get_setting_service),
|
|
118
|
+
) -> SettingOut:
|
|
119
|
+
return await service.upsert_scoped(SettingScope.TENANT, scope_id, key, data)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.delete(API_TENANT_PATH, status_code=STATUS_NO_CONTENT)
|
|
123
|
+
async def delete_tenant_setting(
|
|
124
|
+
scope_id: str,
|
|
125
|
+
key: str,
|
|
126
|
+
service: SettingService = Depends(get_setting_service),
|
|
127
|
+
) -> None:
|
|
128
|
+
if not await service.delete_scoped(SettingScope.TENANT, scope_id, key):
|
|
129
|
+
raise _not_found()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.get(API_USER_PATH, response_model=SettingOut)
|
|
133
|
+
async def get_user_setting(
|
|
134
|
+
scope_id: str,
|
|
135
|
+
key: str,
|
|
136
|
+
service: SettingService = Depends(get_setting_service),
|
|
137
|
+
) -> SettingOut:
|
|
138
|
+
result = await service.get_scoped(SettingScope.USER, scope_id, key)
|
|
139
|
+
if result is None:
|
|
140
|
+
raise _not_found()
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@router.put(API_USER_PATH, response_model=SettingOut)
|
|
145
|
+
async def upsert_user_setting(
|
|
146
|
+
scope_id: str,
|
|
147
|
+
key: str,
|
|
148
|
+
data: SettingUpsert,
|
|
149
|
+
service: SettingService = Depends(get_setting_service),
|
|
150
|
+
) -> SettingOut:
|
|
151
|
+
return await service.upsert_scoped(SettingScope.USER, scope_id, key, data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.delete(API_USER_PATH, status_code=STATUS_NO_CONTENT)
|
|
155
|
+
async def delete_user_setting(
|
|
156
|
+
scope_id: str,
|
|
157
|
+
key: str,
|
|
158
|
+
service: SettingService = Depends(get_setting_service),
|
|
159
|
+
) -> None:
|
|
160
|
+
if not await service.delete_scoped(SettingScope.USER, scope_id, key):
|
|
161
|
+
raise _not_found()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Id-based CRUD (admin tooling) ───────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.post("/", response_model=SettingOut, status_code=STATUS_CREATED)
|
|
168
|
+
async def create_setting(
|
|
169
|
+
data: SettingCreate,
|
|
170
|
+
service: SettingService = Depends(get_setting_service),
|
|
171
|
+
) -> SettingOut:
|
|
172
|
+
return await service.create(data)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.get(API_BY_ID_PATH, response_model=SettingOut)
|
|
176
|
+
async def get_setting(
|
|
177
|
+
setting_id: int, service: SettingService = Depends(get_setting_service)
|
|
178
|
+
) -> SettingOut:
|
|
179
|
+
result = await service.get_by_id(setting_id)
|
|
180
|
+
if result is None:
|
|
181
|
+
raise _not_found()
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.put(API_BY_ID_PATH, response_model=SettingOut)
|
|
186
|
+
async def update_setting(
|
|
187
|
+
setting_id: int,
|
|
188
|
+
data: SettingUpdate,
|
|
189
|
+
service: SettingService = Depends(get_setting_service),
|
|
190
|
+
) -> SettingOut:
|
|
191
|
+
result = await service.update(setting_id, data)
|
|
192
|
+
if result is None:
|
|
193
|
+
raise _not_found()
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.delete(API_BY_ID_PATH, status_code=STATUS_NO_CONTENT)
|
|
198
|
+
async def delete_setting(
|
|
199
|
+
setting_id: int, service: SettingService = Depends(get_setting_service)
|
|
200
|
+
) -> None:
|
|
201
|
+
if not await service.delete(setting_id):
|
|
202
|
+
raise _not_found()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""REST endpoints for the per-module settings admin UI.
|
|
2
|
+
|
|
3
|
+
PUT drops fields whose value is the mask sentinel so the UI can echo back
|
|
4
|
+
masked secrets without clobbering the real value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from settings._module_settings import (
|
|
16
|
+
SECRET_MASK,
|
|
17
|
+
collect_module_settings,
|
|
18
|
+
is_secret_field,
|
|
19
|
+
serialize,
|
|
20
|
+
)
|
|
21
|
+
from settings.constants import MODULE_PACKAGE
|
|
22
|
+
from settings.contracts.events import SettingsReloaded
|
|
23
|
+
from settings.deps import get_setting_service
|
|
24
|
+
from settings.hydrate import hydrate_settings
|
|
25
|
+
from settings.reload import apply_changes_and_reload
|
|
26
|
+
from settings.service import SettingService
|
|
27
|
+
from settings.store import SettingsStore
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/modules", tags=["Settings Modules"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _strip_mask_sentinels(changes: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
"""Drop secret fields whose value is the UI mask sentinel."""
|
|
34
|
+
return {
|
|
35
|
+
name: value
|
|
36
|
+
for name, value in changes.items()
|
|
37
|
+
if not (isinstance(value, str) and value == SECRET_MASK and is_secret_field(name))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("")
|
|
42
|
+
async def list_modules(request: Request) -> dict[str, Any]:
|
|
43
|
+
views = collect_module_settings(request.app)
|
|
44
|
+
return {"modules": serialize(views)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.put("/{package}")
|
|
48
|
+
async def update_module(
|
|
49
|
+
package: str,
|
|
50
|
+
changes: dict[str, Any],
|
|
51
|
+
request: Request,
|
|
52
|
+
service: SettingService = Depends(get_setting_service),
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
registry = getattr(request.app.state, MODULE_PACKAGE).module_registry
|
|
55
|
+
if registry.get(package) is None:
|
|
56
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package")
|
|
57
|
+
|
|
58
|
+
cleaned = _strip_mask_sentinels(changes)
|
|
59
|
+
if not cleaned:
|
|
60
|
+
return {"ok": True, "changed": []}
|
|
61
|
+
|
|
62
|
+
store = SettingsStore(service)
|
|
63
|
+
bus = request.app.state.sm.event_bus
|
|
64
|
+
try:
|
|
65
|
+
await apply_changes_and_reload(request.app, bus, store, package=package, changes=cleaned)
|
|
66
|
+
except ValidationError as exc:
|
|
67
|
+
# ``exc.errors()`` still leaves ``ctx.error`` holding the raw Python
|
|
68
|
+
# exception (not JSON-serializable). Round-trip through pydantic's
|
|
69
|
+
# own JSON encoder to get clean, serializable errors.
|
|
70
|
+
clean = json.loads(exc.json(include_url=False))
|
|
71
|
+
raise HTTPException(status_code=422, detail=clean) from exc
|
|
72
|
+
except KeyError as exc:
|
|
73
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
74
|
+
|
|
75
|
+
return {"ok": True, "changed": sorted(cleaned)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.delete("/{package}/{field}", status_code=status.HTTP_204_NO_CONTENT)
|
|
79
|
+
async def clear_module_field(
|
|
80
|
+
package: str,
|
|
81
|
+
field: str,
|
|
82
|
+
request: Request,
|
|
83
|
+
service: SettingService = Depends(get_setting_service),
|
|
84
|
+
) -> Response:
|
|
85
|
+
registry = getattr(request.app.state, MODULE_PACKAGE).module_registry
|
|
86
|
+
cls = registry.get(package)
|
|
87
|
+
if cls is None:
|
|
88
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package")
|
|
89
|
+
if field not in cls.model_fields:
|
|
90
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown field")
|
|
91
|
+
|
|
92
|
+
store = SettingsStore(service)
|
|
93
|
+
await store.clear_override(package, field)
|
|
94
|
+
|
|
95
|
+
hydrated = await hydrate_settings(cls, store, package)
|
|
96
|
+
services = getattr(request.app.state, package)
|
|
97
|
+
services.settings = hydrated
|
|
98
|
+
|
|
99
|
+
bus = request.app.state.sm.event_bus
|
|
100
|
+
await bus.publish(SettingsReloaded(package=package, changed=(field,)))
|
|
101
|
+
|
|
102
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Inertia view endpoints for the Settings module.
|
|
2
|
+
|
|
3
|
+
Page component identifiers are inlined as string literals here (instead of
|
|
4
|
+
imported from ``settings.constants``) so the ``SM003`` orphan-page doctor
|
|
5
|
+
check — which parses calls to ``inertia.render`` via AST literal matching —
|
|
6
|
+
can correlate views to their ``pages/*.tsx`` files. The literals must match
|
|
7
|
+
``PAGE_BROWSE`` / ``PAGE_CREATE`` / ``PAGE_EDIT`` in ``constants.py``, and
|
|
8
|
+
a test in ``test_settings_module.py`` enforces that invariant.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Depends, Request
|
|
14
|
+
from inertia import InertiaResponse
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
17
|
+
from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict
|
|
18
|
+
from starlette.responses import RedirectResponse
|
|
19
|
+
|
|
20
|
+
from settings._module_settings import collect_module_settings, serialize
|
|
21
|
+
from settings.constants import (
|
|
22
|
+
ERR_SETTING_NOT_FOUND,
|
|
23
|
+
PROP_ERROR,
|
|
24
|
+
PROP_MODULES,
|
|
25
|
+
PROP_SETTING,
|
|
26
|
+
PROP_SETTINGS,
|
|
27
|
+
VIEW_CREATE_PATH,
|
|
28
|
+
VIEW_EDIT_PATH,
|
|
29
|
+
VIEW_MODULES_PATH,
|
|
30
|
+
)
|
|
31
|
+
from settings.contracts.schemas import SettingCreate, SettingUpdate
|
|
32
|
+
from settings.deps import get_setting_service
|
|
33
|
+
from settings.service import SettingService
|
|
34
|
+
|
|
35
|
+
_REDIRECT_SETTINGS = "/settings"
|
|
36
|
+
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/", response_model=None)
|
|
41
|
+
async def browse(
|
|
42
|
+
inertia: InertiaDep,
|
|
43
|
+
service: SettingService = Depends(get_setting_service),
|
|
44
|
+
) -> InertiaResponse:
|
|
45
|
+
items = await service.list_all()
|
|
46
|
+
return await inertia.render(
|
|
47
|
+
"Settings/Browse",
|
|
48
|
+
{PROP_SETTINGS: [item.model_dump(mode="json") for item in items]},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get(VIEW_CREATE_PATH, response_model=None)
|
|
53
|
+
async def create_view(inertia: InertiaDep) -> InertiaResponse:
|
|
54
|
+
return await inertia.render("Settings/Create")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get(VIEW_EDIT_PATH, response_model=None)
|
|
58
|
+
async def edit_view(
|
|
59
|
+
setting_id: int,
|
|
60
|
+
inertia: InertiaDep,
|
|
61
|
+
service: SettingService = Depends(get_setting_service),
|
|
62
|
+
) -> InertiaResponse:
|
|
63
|
+
item = await service.get_by_id(setting_id)
|
|
64
|
+
if item is None:
|
|
65
|
+
return await inertia.render("Settings/Browse", {PROP_ERROR: ERR_SETTING_NOT_FOUND})
|
|
66
|
+
return await inertia.render("Settings/Edit", {PROP_SETTING: item.model_dump(mode="json")})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Form actions (POST/PUT/DELETE → redirect) ─────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/", response_model=None)
|
|
73
|
+
async def create_action(
|
|
74
|
+
request: Request,
|
|
75
|
+
service: SettingService = Depends(get_setting_service),
|
|
76
|
+
) -> RedirectResponse:
|
|
77
|
+
body = await request.json()
|
|
78
|
+
try:
|
|
79
|
+
data = SettingCreate(**body)
|
|
80
|
+
except ValidationError as exc:
|
|
81
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
82
|
+
await service.create(data)
|
|
83
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.put("/{setting_id}", response_model=None)
|
|
87
|
+
async def update_action(
|
|
88
|
+
setting_id: int,
|
|
89
|
+
request: Request,
|
|
90
|
+
service: SettingService = Depends(get_setting_service),
|
|
91
|
+
) -> RedirectResponse:
|
|
92
|
+
body = await request.json()
|
|
93
|
+
try:
|
|
94
|
+
data = SettingUpdate(**body)
|
|
95
|
+
except ValidationError as exc:
|
|
96
|
+
return redirect_back_with_errors(request, validation_errors_to_dict(exc))
|
|
97
|
+
await service.update(setting_id, data)
|
|
98
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.delete("/{setting_id}", response_model=None)
|
|
102
|
+
async def delete_action(
|
|
103
|
+
setting_id: int,
|
|
104
|
+
service: SettingService = Depends(get_setting_service),
|
|
105
|
+
) -> RedirectResponse:
|
|
106
|
+
await service.delete(setting_id)
|
|
107
|
+
return RedirectResponse(_REDIRECT_SETTINGS, status_code=303)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get(VIEW_MODULES_PATH, response_model=None)
|
|
111
|
+
async def modules_view(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
112
|
+
"""Read-only view of every module's pydantic ``BaseSettings`` instance.
|
|
113
|
+
|
|
114
|
+
Auto-discovered from ``app.state.sm.modules``; secrets are masked server-side.
|
|
115
|
+
"""
|
|
116
|
+
views = collect_module_settings(request.app)
|
|
117
|
+
return await inertia.render(
|
|
118
|
+
"Settings/ModulesEdit",
|
|
119
|
+
{PROP_MODULES: serialize(views)},
|
|
120
|
+
)
|