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.
@@ -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
+ )