From fc2c3803020571f856a8e83bce09073533d611aa Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 02:18:34 +0100 Subject: [PATCH 1/3] refactor(settings): follow the module layout (moves only) The scoped key/value service and its helpers, constants, the module's own settings, errors and invalidation -> core/; per-module settings discovery, field metadata, the SYSTEM-scope store, reload and editor props -> module_settings/ (domain area); deps, query parsing, key autocomplete and endpoints -> web/; pages/components -> ui/components. Tests mirror the areas. settings.{service,store,reload,constants,deps,tenant_scope,tenant_view} and settings.endpoints.module_api stay as deprecated sys.modules aliases (remove_in 0.0.46). The #500 re-exports hydrate, module_registry and registration stay at the root until their 0.1.0 removal; the layout test lists them explicitly. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 1 + docs/framework/settings.md | 8 +- docs/module-layout.md | 2 +- .../core/simple_module_core/settings_base.py | 2 +- .../_settings_registration.py | 2 +- .../hosting/tests/test_invalidation_wiring.py | 2 +- host/tests/test_admin_overview_access.py | 2 +- host/tests/test_module_layout.py | 23 +- .../background_tasks/settings.py | 2 +- .../tests/test_background_tasks_settings.py | 8 +- .../tests/test_bg_outbox_guard_savepoint.py | 2 +- .../tests/test_bg_routes_override_api.py | 4 +- modules/branding/branding/assets/reaper.py | 2 +- modules/branding/branding/core/service.py | 12 +- modules/branding/branding/core/services.py | 2 +- modules/branding/branding/core/settings.py | 2 +- .../branding/branding/core/tenant_branding.py | 6 +- .../branding/web/endpoints/tenant_api.py | 8 +- .../tests/assets/reaper/test_branding_reap.py | 2 +- .../reaper/test_branding_reap_ordering.py | 2 +- .../core/presets/test_branding_presets.py | 6 +- .../test_branding_tenant_branding.py | 4 +- .../test_branding_tenant_branding_assets.py | 2 +- .../test_branding_tenant_review_fixes.py | 4 +- .../test_branding_clear_route_scope.py | 2 +- modules/file_storage/file_storage/settings.py | 2 +- .../test_file_storage_restart_settings.py | 2 +- modules/mail/mail/delivery/runtime.py | 4 +- .../tests/core/module/test_mail_lifecycle.py | 6 +- .../runtime/test_oidc_settings_runtime.py | 6 +- modules/settings/settings/cli.py | 6 +- modules/settings/settings/constants.py | 161 +----------- .../settings/settings/contracts/accessor.py | 2 +- .../settings/contracts/invalidation.py | 2 +- .../settings/settings/contracts/registry.py | 2 +- .../settings/settings/contracts/schemas.py | 6 +- modules/settings/settings/core/__init__.py | 6 + .../settings/settings/{ => core}/_announce.py | 2 +- .../settings/settings/{ => core}/_listing.py | 4 +- .../settings/{ => core}/_managed_keys.py | 4 +- .../settings/{ => core}/_row_masking.py | 2 +- .../settings/settings/{ => core}/_secrets.py | 0 .../settings/{ => core}/_unique_write.py | 2 +- modules/settings/settings/core/constants.py | 160 ++++++++++++ .../settings/settings/{ => core}/env_vars.py | 0 .../settings/settings/{ => core}/errors.py | 4 +- .../settings/{ => core}/exceptions.py | 0 .../settings/{ => core}/invalidation.py | 4 +- modules/settings/settings/core/service.py | 237 +++++++++++++++++ .../settings/settings/{ => core}/services.py | 2 +- .../settings/settings/{ => core}/settings.py | 0 .../settings/settings/core/tenant_scope.py | 130 ++++++++++ modules/settings/settings/core/tenant_view.py | 85 +++++++ modules/settings/settings/deps.py | 77 +----- .../settings/settings/endpoints/__init__.py | 1 + .../settings/settings/endpoints/module_api.py | 167 +----------- modules/settings/settings/module.py | 18 +- .../settings/module_settings/__init__.py | 5 + .../{ => module_settings}/_field_meta.py | 0 .../discovery.py} | 8 +- .../props.py} | 2 +- .../settings/module_settings/reload.py | 162 ++++++++++++ .../settings/module_settings/store.py | 92 +++++++ modules/settings/settings/pages/Browse.tsx | 6 +- modules/settings/settings/pages/Create.tsx | 8 +- modules/settings/settings/pages/Edit.tsx | 4 +- .../settings/settings/pages/ModulesEdit.tsx | 2 +- modules/settings/settings/reload.py | 163 +----------- modules/settings/settings/service.py | 238 +----------------- modules/settings/settings/store.py | 93 +------ modules/settings/settings/tenant_scope.py | 131 +--------- modules/settings/settings/tenant_view.py | 86 +------ .../{pages => ui}/components/FieldInput.tsx | 0 .../{pages => ui}/components/FieldSource.tsx | 0 .../components/FormLevelError.tsx | 0 .../{pages => ui}/components/JsonInput.tsx | 0 .../{pages => ui}/components/KeyField.tsx | 2 +- .../components/ModuleFieldRow.tsx | 0 .../{pages => ui}/components/ModuleForm.tsx | 2 +- .../components/ResolvedValue.tsx | 0 .../{pages => ui}/components/ScopeTabs.tsx | 0 .../{pages => ui}/components/StoreCards.tsx | 4 +- .../{pages => ui}/components/StoreTable.tsx | 4 +- .../components/TestConnectionButton.tsx | 2 +- .../{pages => ui}/components/ValueInput.tsx | 6 +- .../{pages => ui}/components/last-test.ts | 0 .../components/validation-errors.test.ts | 0 .../components/validation-errors.ts | 0 modules/settings/settings/web/__init__.py | 5 + .../settings/{ => web}/browse_query.py | 2 +- modules/settings/settings/web/deps.py | 76 ++++++ .../settings/web/endpoints/__init__.py | 0 .../{ => web}/endpoints/_create_form.py | 10 +- .../settings/{ => web}/endpoints/api.py | 24 +- .../settings/web/endpoints/module_api.py | 166 ++++++++++++ .../{ => web}/endpoints/tenant_api.py | 12 +- .../settings/{ => web}/endpoints/views.py | 28 +-- .../settings/settings/{ => web}/known_keys.py | 0 modules/settings/tests-js/JsonFields.test.tsx | 6 +- modules/settings/tests-js/ModuleForm.test.tsx | 4 +- .../settings/tests-js/RestartHint.test.tsx | 4 +- modules/settings/tests-js/ScopeTabs.test.tsx | 2 +- .../tests-js/settings_FormLevelError.test.tsx | 2 +- .../settings_ModuleFieldLayout.test.tsx | 6 +- .../settings_ModuleFormRevert.test.tsx | 4 +- .../settings_TestConnectionButton.test.tsx | 2 +- .../contracts}/test_settings_accessor.py | 2 +- .../contracts}/test_settings_events.py | 0 .../test_settings_framework_reexports.py | 3 +- .../contracts}/test_settings_schemas.py | 0 .../module/test_settings_cli_import.py} | 16 +- .../test_settings_host_restart_flags.py | 2 +- .../{ => core/module}/test_settings_module.py | 6 +- .../test_settings_secret_json_roundtrip.py | 2 +- .../secrets}/test_settings_secret_masking.py | 4 +- .../secrets}/test_settings_secret_urls.py | 6 +- .../test_settings_store_secret_masking.py} | 8 +- .../test_settings_typed_secret_masking.py | 6 +- .../test_settings_commit_invalidation.py | 8 +- .../test_settings_concurrent_overrides.py} | 4 +- .../test_settings_default_clears_override.py | 4 +- .../service}/test_settings_domain_errors.py | 6 +- .../test_settings_duplicate_and_race.py} | 2 +- .../test_settings_generic_module_guard.py | 2 +- .../service}/test_settings_group_metadata.py | 4 +- .../service}/test_settings_service.py | 2 +- .../tenant/test_settings_tenant_settings.py} | 6 +- .../fields/test_settings_env_pinned.py} | 12 +- .../fields}/test_settings_env_unprefixed.py | 0 .../fields}/test_settings_field_sources.py | 14 +- ...ings_lenient_related_fields_validators.py} | 0 .../fields/test_settings_module_settings.py} | 6 +- .../test_settings_module_settings_render.py} | 0 ...est_settings_module_settings_serialize.py} | 4 +- .../fields/test_settings_testable_checks.py} | 0 .../hydrate/test_settings_hydrate.py} | 4 +- .../test_settings_hydrate_related_fields.py} | 0 .../hydrate/test_settings_module_registry.py} | 0 .../hydrate/test_settings_registration.py} | 4 +- .../hydrate/test_settings_reload.py} | 10 +- .../store/test_settings_store.py} | 4 +- .../store/test_settings_store_filters.py} | 6 +- .../tests/{ => web/api}/test_settings_api.py | 2 +- .../test_settings_clear_route_per_scope.py} | 4 +- .../api/test_settings_known_keys_meta.py} | 10 +- .../api/test_settings_module_api.py} | 4 +- .../test_settings_module_api_json_fields.py} | 4 +- .../api/test_settings_upload_key_delete.py} | 4 +- .../{ => web/api}/test_settings_view_authz.py | 0 modules/site_lock/site_lock/core/settings.py | 2 +- modules/site_lock/site_lock/core/state.py | 2 +- .../core/settings/test_site_lock_settings.py | 2 +- .../tenants/tenants/web/endpoints/views.py | 6 +- modules/users/tests/_users_app_builders.py | 4 +- modules/users/tests/test_settings.py | 8 +- modules/users/users/settings.py | 2 +- tests/test_hydration_lifespan.py | 4 +- 157 files changed, 1460 insertions(+), 1344 deletions(-) create mode 100644 modules/settings/settings/core/__init__.py rename modules/settings/settings/{ => core}/_announce.py (95%) rename modules/settings/settings/{ => core}/_listing.py (97%) rename modules/settings/settings/{ => core}/_managed_keys.py (95%) rename modules/settings/settings/{ => core}/_row_masking.py (98%) rename modules/settings/settings/{ => core}/_secrets.py (100%) rename modules/settings/settings/{ => core}/_unique_write.py (95%) create mode 100644 modules/settings/settings/core/constants.py rename modules/settings/settings/{ => core}/env_vars.py (100%) rename modules/settings/settings/{ => core}/errors.py (86%) rename modules/settings/settings/{ => core}/exceptions.py (100%) rename modules/settings/settings/{ => core}/invalidation.py (96%) create mode 100644 modules/settings/settings/core/service.py rename modules/settings/settings/{ => core}/services.py (95%) rename modules/settings/settings/{ => core}/settings.py (100%) create mode 100644 modules/settings/settings/core/tenant_scope.py create mode 100644 modules/settings/settings/core/tenant_view.py create mode 100644 modules/settings/settings/module_settings/__init__.py rename modules/settings/settings/{ => module_settings}/_field_meta.py (100%) rename modules/settings/settings/{_module_settings.py => module_settings/discovery.py} (97%) rename modules/settings/settings/{_module_settings_props.py => module_settings/props.py} (97%) create mode 100644 modules/settings/settings/module_settings/reload.py create mode 100644 modules/settings/settings/module_settings/store.py rename modules/settings/settings/{pages => ui}/components/FieldInput.tsx (100%) rename modules/settings/settings/{pages => ui}/components/FieldSource.tsx (100%) rename modules/settings/settings/{pages => ui}/components/FormLevelError.tsx (100%) rename modules/settings/settings/{pages => ui}/components/JsonInput.tsx (100%) rename modules/settings/settings/{pages => ui}/components/KeyField.tsx (99%) rename modules/settings/settings/{pages => ui}/components/ModuleFieldRow.tsx (100%) rename modules/settings/settings/{pages => ui}/components/ModuleForm.tsx (99%) rename modules/settings/settings/{pages => ui}/components/ResolvedValue.tsx (100%) rename modules/settings/settings/{pages => ui}/components/ScopeTabs.tsx (100%) rename modules/settings/settings/{pages => ui}/components/StoreCards.tsx (96%) rename modules/settings/settings/{pages => ui}/components/StoreTable.tsx (97%) rename modules/settings/settings/{pages => ui}/components/TestConnectionButton.tsx (99%) rename modules/settings/settings/{pages => ui}/components/ValueInput.tsx (94%) rename modules/settings/settings/{pages => ui}/components/last-test.ts (100%) rename modules/settings/settings/{pages => ui}/components/validation-errors.test.ts (100%) rename modules/settings/settings/{pages => ui}/components/validation-errors.ts (100%) create mode 100644 modules/settings/settings/web/__init__.py rename modules/settings/settings/{ => web}/browse_query.py (96%) create mode 100644 modules/settings/settings/web/deps.py create mode 100644 modules/settings/settings/web/endpoints/__init__.py rename modules/settings/settings/{ => web}/endpoints/_create_form.py (82%) rename modules/settings/settings/{ => web}/endpoints/api.py (97%) create mode 100644 modules/settings/settings/web/endpoints/module_api.py rename modules/settings/settings/{ => web}/endpoints/tenant_api.py (92%) rename modules/settings/settings/{ => web}/endpoints/views.py (95%) rename modules/settings/settings/{ => web}/known_keys.py (100%) rename modules/settings/tests/{ => core/contracts}/test_settings_accessor.py (99%) rename modules/settings/tests/{ => core/contracts}/test_settings_events.py (100%) rename modules/settings/tests/{ => core/contracts}/test_settings_framework_reexports.py (94%) rename modules/settings/tests/{ => core/contracts}/test_settings_schemas.py (100%) rename modules/settings/tests/{test_cli_import.py => core/module/test_settings_cli_import.py} (89%) rename modules/settings/tests/{ => core/module}/test_settings_host_restart_flags.py (92%) rename modules/settings/tests/{ => core/module}/test_settings_module.py (86%) rename modules/settings/tests/{ => core/secrets}/test_settings_secret_json_roundtrip.py (96%) rename modules/settings/tests/{ => core/secrets}/test_settings_secret_masking.py (98%) rename modules/settings/tests/{ => core/secrets}/test_settings_secret_urls.py (96%) rename modules/settings/tests/{test_store_secret_masking.py => core/secrets/test_settings_store_secret_masking.py} (97%) rename modules/settings/tests/{ => core/secrets}/test_settings_typed_secret_masking.py (95%) rename modules/settings/tests/{ => core/service}/test_settings_commit_invalidation.py (93%) rename modules/settings/tests/{test_concurrent_overrides.py => core/service/test_settings_concurrent_overrides.py} (97%) rename modules/settings/tests/{ => core/service}/test_settings_default_clears_override.py (96%) rename modules/settings/tests/{ => core/service}/test_settings_domain_errors.py (58%) rename modules/settings/tests/{test_duplicate_and_race.py => core/service/test_settings_duplicate_and_race.py} (98%) rename modules/settings/tests/{ => core/service}/test_settings_generic_module_guard.py (98%) rename modules/settings/tests/{ => core/service}/test_settings_group_metadata.py (88%) rename modules/settings/tests/{ => core/service}/test_settings_service.py (99%) rename modules/settings/tests/{test_tenant_settings.py => core/tenant/test_settings_tenant_settings.py} (98%) rename modules/settings/tests/{test_env_pinned.py => module_settings/fields/test_settings_env_pinned.py} (92%) rename modules/settings/tests/{ => module_settings/fields}/test_settings_env_unprefixed.py (100%) rename modules/settings/tests/{ => module_settings/fields}/test_settings_field_sources.py (93%) rename modules/settings/tests/{test_lenient_related_fields_validators.py => module_settings/fields/test_settings_lenient_related_fields_validators.py} (100%) rename modules/settings/tests/{test_module_settings.py => module_settings/fields/test_settings_module_settings.py} (91%) rename modules/settings/tests/{test_module_settings_render.py => module_settings/fields/test_settings_module_settings_render.py} (100%) rename modules/settings/tests/{test_module_settings_serialize.py => module_settings/fields/test_settings_module_settings_serialize.py} (96%) rename modules/settings/tests/{test_testable_checks.py => module_settings/fields/test_settings_testable_checks.py} (100%) rename modules/settings/tests/{test_hydrate.py => module_settings/hydrate/test_settings_hydrate.py} (98%) rename modules/settings/tests/{test_hydrate_related_fields.py => module_settings/hydrate/test_settings_hydrate_related_fields.py} (100%) rename modules/settings/tests/{test_module_registry.py => module_settings/hydrate/test_settings_module_registry.py} (100%) rename modules/settings/tests/{test_registration.py => module_settings/hydrate/test_settings_registration.py} (93%) rename modules/settings/tests/{test_reload.py => module_settings/hydrate/test_settings_reload.py} (92%) rename modules/settings/tests/{test_store.py => module_settings/store/test_settings_store.py} (95%) rename modules/settings/tests/{test_store_filters.py => module_settings/store/test_settings_store_filters.py} (98%) rename modules/settings/tests/{ => web/api}/test_settings_api.py (99%) rename modules/settings/tests/{test_clear_route_per_scope.py => web/api/test_settings_clear_route_per_scope.py} (95%) rename modules/settings/tests/{test_known_keys_meta.py => web/api/test_settings_known_keys_meta.py} (94%) rename modules/settings/tests/{test_module_api.py => web/api/test_settings_module_api.py} (96%) rename modules/settings/tests/{test_module_api_json_fields.py => web/api/test_settings_module_api_json_fields.py} (97%) rename modules/settings/tests/{test_upload_key_delete.py => web/api/test_settings_upload_key_delete.py} (98%) rename modules/settings/tests/{ => web/api}/test_settings_view_authz.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc755b1f5..b605c207a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ All notable changes to this project are documented in this file. The format is b - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. - `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. +- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.store`, `settings.reload`, `settings.constants`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged. - `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain. - `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46. - `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.host_resolver`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. diff --git a/docs/framework/settings.md b/docs/framework/settings.md index 97a6e555e..d70818b90 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -110,8 +110,8 @@ reload and the generic key/value API on top. The old plugin paths (`settings.registration`, `settings.module_registry`, `settings.hydrate`) still re-export these and emit a `DeprecationWarning`; -`settings.models`, `settings._secrets` and the moved names in -`settings.store` / `settings.constants` re-export them silently. They go no +`settings.models`, `settings.core._secrets` and the moved names in +`settings.module_settings.store` / `settings.core.constants` re-export them silently. They go no earlier than 0.1.0. A Celery worker or CLI script never runs the lifespan, so it hydrates @@ -163,10 +163,10 @@ async def users_config(state: UsersStateDep): ## DB-backed settings -After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/admin/settings/`. The CRUD layer is `SettingService` (in `settings.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it: +After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/admin/settings/`. The CRUD layer is `SettingService` (in `settings.core.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it: ```python -from settings.service import SettingService +from settings.core.service import SettingService from settings.contracts.accessor import SettingsAccessor accessor = SettingsAccessor(SettingService(session), registry) diff --git a/docs/module-layout.md b/docs/module-layout.md index 11a06140d..25e5458b5 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -224,7 +224,7 @@ when a later module PR moves files. | mail | 2 (+2 shims) | 0 | follows this layout | | oidc | 3 (+3 shims) | 0 | follows this layout | | permissions | 3 (+2 shims) | 0 | follows this layout | -| settings | 31 | 43 | flat | +| settings | 4 (+7 shims, +3 deprecated re-exports) | 0 | follows this layout | | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | | tenants | 3 (+6 shims) | 0 | follows this layout | diff --git a/framework/core/simple_module_core/settings_base.py b/framework/core/simple_module_core/settings_base.py index 646b54f43..32ae92d08 100644 --- a/framework/core/simple_module_core/settings_base.py +++ b/framework/core/simple_module_core/settings_base.py @@ -1,7 +1,7 @@ """Base class for DB-backed settings that must not read the environment. Module and host settings are hydrated from the settings store at boot and -hot-swapped through ``settings.reload.apply_changes_and_reload``. The DB is +hot-swapped through ``settings.module_settings.reload.apply_changes_and_reload``. The DB is meant to be the only source of truth for them. Subclassing ``BaseSettings`` and simply omitting ``env_prefix`` does **not** diff --git a/framework/hosting/simple_module_hosting/_settings_registration.py b/framework/hosting/simple_module_hosting/_settings_registration.py index 9e505f602..8ddc292b5 100644 --- a/framework/hosting/simple_module_hosting/_settings_registration.py +++ b/framework/hosting/simple_module_hosting/_settings_registration.py @@ -34,7 +34,7 @@ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic] continue # Match the convention actually used by modules: `app.state.` # (snake_case package name, e.g. `background_tasks`), which aligns - # with Settings-module autodiscovery in `settings._module_settings`. + # with Settings-module autodiscovery in `settings.module_settings.discovery`. package = cls.__module__.split(".", 1)[0] candidates = (package, mod.meta.name.lower()) if any(hasattr(app.state, c) for c in candidates): diff --git a/framework/hosting/tests/test_invalidation_wiring.py b/framework/hosting/tests/test_invalidation_wiring.py index 33f1cd3ea..5a203c6c9 100644 --- a/framework/hosting/tests/test_invalidation_wiring.py +++ b/framework/hosting/tests/test_invalidation_wiring.py @@ -236,7 +236,7 @@ async def test_both_new_fields_reach_the_settings_admin_surface(self, app): and ``import-from-env`` actually read — rather than through ``model_fields``, which would only restate the dataclass back to itself. """ - from settings._module_settings import collect_module_settings + from settings.module_settings.discovery import collect_module_settings views = collect_module_settings(app) background = next((v for v in views if v.package == "background_tasks"), None) diff --git a/host/tests/test_admin_overview_access.py b/host/tests/test_admin_overview_access.py index 112a5a110..2247e9ae6 100644 --- a/host/tests/test_admin_overview_access.py +++ b/host/tests/test_admin_overview_access.py @@ -106,7 +106,7 @@ async def test_permission_holder_without_the_admin_role_is_admitted(host_app) -> This account can open ``/admin/settings/`` on its ``settings.view`` grant, so refusing it the overview that links there is self-contradictory. """ - from settings.constants import PERM_VIEW + from settings.core.constants import PERM_VIEW client = await _client_for( host_app, diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 11eed561e..a3627a7dc 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -41,9 +41,17 @@ "mail": frozenset({"delivery"}), "oidc": frozenset({"identity"}), "permissions": frozenset({"grants"}), + "settings": frozenset({"module_settings"}), "tenants": frozenset({"invitations", "resolution"}), } +# Plain re-export shims (#500) that predate the layout: public compat paths kept +# at the root until their own 0.1.0 deprecation expires (host/tests/test_deprecation_expiry.py), +# then deleted together with the entry here. +DEPRECATED_ROOT: dict[str, frozenset[str]] = { + "settings": frozenset({"hydrate.py", "module_registry.py", "registration.py"}), +} + # Frozen inventory, not a computed baseline: moves must remove their old entries. LEGACY_FLAT: dict[str, frozenset[str]] = { "background_tasks": frozenset( @@ -70,15 +78,6 @@ uploads.py visibility.py worker.py worker_support.py """.split() ), - "settings": frozenset( - """ - _announce.py _field_meta.py _listing.py _managed_keys.py _module_settings.py - _module_settings_props.py _row_masking.py _secrets.py _unique_write.py - browse_query.py constants.py deps.py endpoints env_vars.py errors.py exceptions.py - hydrate.py invalidation.py known_keys.py module_registry.py registration.py - reload.py service.py services.py settings.py store.py tenant_scope.py tenant_view.py - """.split() - ), "users": frozenset( """ admin administrators.py audit.py auth_local backend.py bootstrap.py constants.py @@ -124,7 +123,11 @@ def test_module_root_layout(module: str) -> None: and not _is_shim_package(p) ) } - allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) + deprecated = DEPRECATED_ROOT.get(module, frozenset()) + assert deprecated <= {p.name for p in package.iterdir()}, ( + f"{module}: remove expired DEPRECATED_ROOT entries" + ) + allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) | deprecated legacy = LEGACY_FLAT.get(module, frozenset()) if module in LEGACY_FLAT: assert legacy, f"{module}: remove the empty LEGACY_FLAT entry" diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index b52da78c6..e9a06aea9 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -2,7 +2,7 @@ Values come from defaults at boot, then get hydrated from the DB by the hosting lifespan before module ``on_startup`` runs. Runtime changes go -through ``settings.reload.apply_changes_and_reload``. +through ``settings.module_settings.reload.apply_changes_and_reload``. The broker and result-backend URLs are deployment plumbing rather than module config, so they stay env-readable through ``SM_REDIS_URL`` — one variable that diff --git a/modules/background_tasks/tests/test_background_tasks_settings.py b/modules/background_tasks/tests/test_background_tasks_settings.py index 33c8bf3ae..401fcc208 100644 --- a/modules/background_tasks/tests/test_background_tasks_settings.py +++ b/modules/background_tasks/tests/test_background_tasks_settings.py @@ -45,8 +45,8 @@ def test_soft_time_limit_must_be_below_hard_limit() -> None: @pytest.mark.asyncio async def test_stored_invalid_limit_pair_does_not_break_lenient_hydration(db_session) -> None: - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings store = SettingsStore(SettingService(db_session)) @@ -64,8 +64,8 @@ async def test_hydration_drops_the_stored_soft_limit(db_session, caplog, monkeyp """With the hard limit pinned by env, the pair rule reported on ``task_time_limit`` cannot be satisfied by dropping it; the stored soft limit is the half to drop (#548).""" - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings monkeypatch.setenv("SM_BG_TASKS_TASK_TIME_LIMIT", "50") diff --git a/modules/background_tasks/tests/test_bg_outbox_guard_savepoint.py b/modules/background_tasks/tests/test_bg_outbox_guard_savepoint.py index 03c06e090..40b7d0531 100644 --- a/modules/background_tasks/tests/test_bg_outbox_guard_savepoint.py +++ b/modules/background_tasks/tests/test_bg_outbox_guard_savepoint.py @@ -4,7 +4,7 @@ - N1: ``before_commit`` also fires when a SAVEPOINT is released, so the guard refused ``begin_nested()`` — the race-safe insert idiom, used by - ``settings._unique_write`` — and such a handler could never deliver. + ``settings.core._unique_write`` — and such a handler could never deliver. - N2: committing through the session's ``Connection`` skipped ``Session.before_commit``, made the effect durable, and then failed the attempt anyway — so every retry committed the effect again. diff --git a/modules/background_tasks/tests/test_bg_routes_override_api.py b/modules/background_tasks/tests/test_bg_routes_override_api.py index e3fb528c9..42977eb0d 100644 --- a/modules/background_tasks/tests/test_bg_routes_override_api.py +++ b/modules/background_tasks/tests/test_bg_routes_override_api.py @@ -31,8 +31,8 @@ async def test_invalid_override_is_rejected_with_422(authenticated_client, app, async def test_stored_invalid_override_does_not_break_web_hydration(app, db_session, caplog): - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_hosting._hydrate_step import hydrate_all store = SettingsStore(SettingService(db_session)) diff --git a/modules/branding/branding/assets/reaper.py b/modules/branding/branding/assets/reaper.py index 437f2eddd..69c0c3c0e 100644 --- a/modules/branding/branding/assets/reaper.py +++ b/modules/branding/branding/assets/reaper.py @@ -40,7 +40,7 @@ async def referenced_ids(db: AsyncSession, tenant_id: str | None) -> set[str]: """File ids the owner's image fields hold (``None`` = the system's).""" from settings.contracts.schemas import SettingScope - from settings.service import SettingService + from settings.core.service import SettingService scope, scope_id = (SettingScope.TENANT, tenant_id) if tenant_id else (SettingScope.SYSTEM, "") rows = await SettingService(db).list_by_scope_unmasked(scope, scope_id) diff --git a/modules/branding/branding/core/service.py b/modules/branding/branding/core/service.py index 48ddb23cc..0bafa1967 100644 --- a/modules/branding/branding/core/service.py +++ b/modules/branding/branding/core/service.py @@ -1,7 +1,7 @@ """Branding service — reads current branding and applies admin changes. There is no branding DB table: values live in the shared settings store. Writes -go through ``settings.reload.apply_changes_and_reload`` which validates against +go through ``settings.module_settings.reload.apply_changes_and_reload`` which validates against ``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding`` and publishes ``SettingsReloaded``. """ @@ -70,9 +70,9 @@ async def apply(self, changes: dict[str, Any]) -> BrandingOut: """Persist and hot-swap the given field changes, then return current.""" # Plugin→plugin imports (settings is a declared dependency); kept local # so import order during discovery stays tolerant. - from settings.reload import apply_changes_and_reload - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.reload import apply_changes_and_reload + from settings.module_settings.store import SettingsStore # With the bus, so the write publishes ``settings.values`` and other # workers' merged tenant caches (which inherit system values) drop. @@ -97,8 +97,8 @@ async def _swap_asset(self, field: str, file_id: str) -> BrandingOut: """ # The pre-save value comes from the DB-backed settings, not app.state: # the in-process object only changes after commit. - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings from branding.core.settings import BrandingSettings diff --git a/modules/branding/branding/core/services.py b/modules/branding/branding/core/services.py index 0dc1a6504..bad68c290 100644 --- a/modules/branding/branding/core/services.py +++ b/modules/branding/branding/core/services.py @@ -2,7 +2,7 @@ Stored as ``app.state.branding`` by :meth:`BrandingModule.register_settings` (via ``register_module_settings``). -``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when +``settings`` is hot-swapped by ``settings.module_settings.reload.apply_changes_and_reload`` when an admin saves changes. """ diff --git a/modules/branding/branding/core/settings.py b/modules/branding/branding/core/settings.py index 59e53f8eb..6055bc25c 100644 --- a/modules/branding/branding/core/settings.py +++ b/modules/branding/branding/core/settings.py @@ -3,7 +3,7 @@ The four values an administrator can customise (app name, logo, favicon, primary colour) are stored in the shared settings store at SYSTEM scope, hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save -through ``settings.reload.apply_changes_and_reload``. +through ``settings.module_settings.reload.apply_changes_and_reload``. Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the frontend derives a download URL from the id. diff --git a/modules/branding/branding/core/tenant_branding.py b/modules/branding/branding/core/tenant_branding.py index a7a04e074..dc99c6f4e 100644 --- a/modules/branding/branding/core/tenant_branding.py +++ b/modules/branding/branding/core/tenant_branding.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: from fastapi import FastAPI - from settings.service import SettingService + from settings.core.service import SettingService from simple_module_core.invalidation import Invalidation, InvalidationBus from starlette.requests import Request @@ -94,8 +94,8 @@ def forget(app: FastAPI, tenant_id: str | None = None) -> None: def subscribe(bus: InvalidationBus, app: FastAPI) -> None: - from settings.constants import INVALIDATION_CHANNEL from settings.contracts.invalidation import parse_invalidation_key + from settings.core.constants import INVALIDATION_CHANNEL def on_settings_changed(inv: Invalidation) -> None: tenant_id, key = parse_invalidation_key(inv.key) @@ -138,7 +138,7 @@ async def read_overrides(app: FastAPI, tenant_id: str) -> dict[str, str]: Only for reads ahead of a request's own work (shared props, the asset routes): a request that has already written must use its own session. """ - from settings.service import SettingService + from settings.core.service import SettingService async with app.state.sm.db.session_factory() as db: return await overrides_from(SettingService(db), tenant_id) diff --git a/modules/branding/branding/web/endpoints/tenant_api.py b/modules/branding/branding/web/endpoints/tenant_api.py index cccdd5c48..64d19d9c8 100644 --- a/modules/branding/branding/web/endpoints/tenant_api.py +++ b/modules/branding/branding/web/endpoints/tenant_api.py @@ -18,11 +18,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from file_storage.deps import get_file_storage_service from file_storage.service import FileStorageService -from settings.constants import PERM_TENANT_EDIT from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.deps import get_setting_service -from settings.service import SettingService -from settings.tenant_scope import active_tenant +from settings.core.constants import PERM_TENANT_EDIT +from settings.core.service import SettingService +from settings.core.tenant_scope import active_tenant +from settings.web.deps import get_setting_service from simple_module_hosting.permissions import RequiresPermission from branding.assets.images import store_image, validate_image diff --git a/modules/branding/tests/assets/reaper/test_branding_reap.py b/modules/branding/tests/assets/reaper/test_branding_reap.py index 8cef565f7..877bef777 100644 --- a/modules/branding/tests/assets/reaper/test_branding_reap.py +++ b/modules/branding/tests/assets/reaper/test_branding_reap.py @@ -13,7 +13,7 @@ from branding.web.endpoints import tenant_api from file_storage.models import StoredFile from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db import all_tenants from sqlalchemy import select diff --git a/modules/branding/tests/assets/reaper/test_branding_reap_ordering.py b/modules/branding/tests/assets/reaper/test_branding_reap_ordering.py index ae50d5619..d16fbe5ab 100644 --- a/modules/branding/tests/assets/reaper/test_branding_reap_ordering.py +++ b/modules/branding/tests/assets/reaper/test_branding_reap_ordering.py @@ -14,7 +14,7 @@ from branding.core.tenant_branding import TenantCache from file_storage.models import StoredFile from settings.contracts.schemas import SettingScope -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db import all_tenants from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/modules/branding/tests/core/presets/test_branding_presets.py b/modules/branding/tests/core/presets/test_branding_presets.py index 4289f8008..06bf8f0f6 100644 --- a/modules/branding/tests/core/presets/test_branding_presets.py +++ b/modules/branding/tests/core/presets/test_branding_presets.py @@ -11,9 +11,9 @@ import httpx import pytest from branding.core.presets import BUILTIN_PRESETS, PRESET_FIELDS, BrandingPreset, find_preset -from settings.invalidation import reload_package -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.invalidation import reload_package +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore # ── Unit: definitions ────────────────────────────────────────────────── diff --git a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding.py b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding.py index 4cf670753..01ffd16fa 100644 --- a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding.py +++ b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding.py @@ -11,10 +11,10 @@ import httpx import pytest from branding.core import tenant_branding -from settings.constants import INVALIDATION_CHANNEL from settings.contracts.invalidation import invalidation_key from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.constants import INVALIDATION_CHANNEL +from settings.core.service import SettingService from simple_module_hosting.settings import Settings from simple_module_test.database import database_url_for_tests diff --git a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py index db0ec84b4..5680f469f 100644 --- a/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py +++ b/modules/branding/tests/core/tenant_branding/test_branding_tenant_branding_assets.py @@ -15,7 +15,7 @@ from file_storage.models import StoredFile from file_storage.scope import PLATFORM_TENANT_ID from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db import all_tenants from sqlalchemy import select from tenants.models import Tenant diff --git a/modules/branding/tests/core/tenant_branding/test_branding_tenant_review_fixes.py b/modules/branding/tests/core/tenant_branding/test_branding_tenant_review_fixes.py index 487385c0a..dade97796 100644 --- a/modules/branding/tests/core/tenant_branding/test_branding_tenant_review_fixes.py +++ b/modules/branding/tests/core/tenant_branding/test_branding_tenant_review_fixes.py @@ -8,10 +8,10 @@ from branding.core.services import BrandingServices from branding.core.settings import BrandingSettings from branding.core.tenant_branding import TenantCache, merge -from settings.constants import INVALIDATION_CHANNEL from settings.contracts.invalidation import parse_invalidation_key from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.constants import INVALIDATION_CHANNEL +from settings.core.service import SettingService from simple_module_db import all_tenants from sqlalchemy import select diff --git a/modules/branding/tests/web/endpoints/test_branding_clear_route_scope.py b/modules/branding/tests/web/endpoints/test_branding_clear_route_scope.py index 43076f3b3..0fe0448d8 100644 --- a/modules/branding/tests/web/endpoints/test_branding_clear_route_scope.py +++ b/modules/branding/tests/web/endpoints/test_branding_clear_route_scope.py @@ -7,7 +7,7 @@ from __future__ import annotations from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db.stored_settings import SYSTEM_SCOPE_ID KEY = "branding.logo_file_id" diff --git a/modules/file_storage/file_storage/settings.py b/modules/file_storage/file_storage/settings.py index 80d47164d..2666e6088 100644 --- a/modules/file_storage/file_storage/settings.py +++ b/modules/file_storage/file_storage/settings.py @@ -3,7 +3,7 @@ Construction no longer reads ``SM_FILE_STORAGE_*`` environment variables. Values come from pydantic defaults at boot, then get hydrated from the DB by the hosting lifespan before module ``on_startup`` runs. Runtime changes -go through ``settings.reload.apply_changes_and_reload``. +go through ``settings.module_settings.reload.apply_changes_and_reload``. Fields are grouped via ``json_schema_extra={"group": ...}`` so the admin UI can render them under their respective backend sections. diff --git a/modules/file_storage/tests/test_file_storage_restart_settings.py b/modules/file_storage/tests/test_file_storage_restart_settings.py index 1d5e152d1..9989dcc59 100644 --- a/modules/file_storage/tests/test_file_storage_restart_settings.py +++ b/modules/file_storage/tests/test_file_storage_restart_settings.py @@ -19,7 +19,7 @@ ) def test_backend_construction_fields_advertise_restart(name): from file_storage.settings import FileStorageSettings - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view field = _field_view(name, FileStorageSettings(), "SM_FILE_STORAGE_") assert field.requires_restart is True diff --git a/modules/mail/mail/delivery/runtime.py b/modules/mail/mail/delivery/runtime.py index cdbff1dec..524c9a13f 100644 --- a/modules/mail/mail/delivery/runtime.py +++ b/modules/mail/mail/delivery/runtime.py @@ -5,8 +5,8 @@ import os from typing import Any -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore from simple_module_core.environments import NON_PROD_ENVIRONMENTS from simple_module_core.health import HealthCheckResult, HealthStatus from simple_module_core.mail import MailSender, current_mail_sender, install_mail_sender diff --git a/modules/mail/tests/core/module/test_mail_lifecycle.py b/modules/mail/tests/core/module/test_mail_lifecycle.py index 55e7b990a..a81f5e442 100644 --- a/modules/mail/tests/core/module/test_mail_lifecycle.py +++ b/modules/mail/tests/core/module/test_mail_lifecycle.py @@ -4,9 +4,9 @@ from mail.delivery.sender import SmtpMailSender from settings.contracts.events import SettingsReloaded -from settings.reload import apply_changes_and_reload -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.reload import apply_changes_and_reload +from settings.module_settings.store import SettingsStore from simple_module_core import mail as core_mail from simple_module_core.mail import ConsoleMailSender, resolve_mail_sender from simple_module_db.transaction import finalize_session diff --git a/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py b/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py index 2657652c0..e59ec1179 100644 --- a/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py +++ b/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py @@ -1,9 +1,9 @@ """OIDC policy uses hydrated settings even without the Settings UI (#192).""" from oidc.core.settings import OidcSettings -from settings.invalidation import reload_package -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.invalidation import reload_package +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore from simple_module_core.module_settings import module_settings_registry diff --git a/modules/settings/settings/cli.py b/modules/settings/settings/cli.py index 80fcfb397..007426fbd 100644 --- a/modules/settings/settings/cli.py +++ b/modules/settings/settings/cli.py @@ -17,8 +17,8 @@ from simple_module_core.module_settings import module_settings_registry from simple_module_core.settings_hydrate import value_type_for_field -from settings.env_vars import env_prefix_for -from settings.store import SettingsStore +from settings.core.env_vars import env_prefix_for +from settings.module_settings.store import SettingsStore app = typer.Typer(help="Settings module administration.", no_args_is_help=True) @@ -55,7 +55,7 @@ def import_from_env() -> None: from simple_module_hosting.app_builder import create_app from simple_module_hosting.settings import Settings - from settings.service import SettingService + from settings.core.service import SettingService fastapi_app = create_app(Settings()) diff --git a/modules/settings/settings/constants.py b/modules/settings/settings/constants.py index d299cbf80..7543a291a 100644 --- a/modules/settings/settings/constants.py +++ b/modules/settings/settings/constants.py @@ -1,160 +1,11 @@ -"""Centralized constants for the Settings module. - -Keeps module-level strings (route prefixes, permission ids, table names, -menu metadata, error messages, field limits, env prefix, i18n namespace, -scope identifiers) in one place so nothing is duplicated in Python or -inline-literal'd at call sites. -""" +"""Moved to :mod:`settings.core.constants`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from typing import Final - -# Re-exported (#500): these now live with the table in the framework. -from simple_module_db.stored_settings import ( # noqa: F401 - ALL_VALUE_TYPES, - DESCRIPTION_MAX_LENGTH, - KEY_MAX_LENGTH, - SCOPE_ID_MAX_LENGTH, - SCOPE_MAX_LENGTH, - SCOPE_SYSTEM, - SCOPE_TENANT, - SCOPE_USER, - SYSTEM_SCOPE_ID, - TABLE_SETTING, - UQ_SCOPE_KEY, - VALUE_MAX_LENGTH, - VALUE_TYPE_BOOL, - VALUE_TYPE_FLOAT, - VALUE_TYPE_INT, - VALUE_TYPE_JSON, - VALUE_TYPE_MAX_LENGTH, - VALUE_TYPE_STRING, -) - -# ── Module identity ────────────────────────────────────────────────── -MODULE_NAME: Final = "Settings" -MODULE_PACKAGE: Final = "settings" -ENV_PREFIX: Final = "SM_SETTINGS_" -LOCALE_NAMESPACE: Final = MODULE_PACKAGE - -# ── Scopes ─────────────────────────────────────────────────────────── -# Scope, value-type, table and field-limit constants moved to -# ``simple_module_db.stored_settings`` with the table itself (#500); they are -# re-exported from the import block at the top of this file. -ALL_SCOPES: Final = (SCOPE_SYSTEM, SCOPE_TENANT, SCOPE_USER) - -# ── Secrets ────────────────────────────────────────────────────────── -# The sensitive-key allowlist lives in ``simple_module_core.secret_fields``. -SENSITIVE_PLACEHOLDER: Final = "********" - -# ── Routing ────────────────────────────────────────────────────────── -API_PREFIX: Final = "/api/settings" -VIEW_PREFIX: Final = "/admin/settings" -VIEW_CREATE_PATH: Final = "/create" -VIEW_EDIT_PATH: Final = "/{setting_id}/edit" -VIEW_MODULES_PATH: Final = "/modules" -"""Legacy path for the per-module forms. Those now live at the section root; -this redirects, so existing links and bookmarks keep working.""" - -VIEW_STORE_PATH: Final = "/store" -"""Raw key/value store. Demoted from the section root: it is a database view, -and an admin looking for "settings" almost always wants the module forms.""" -API_BY_ID_PATH: Final = "/{setting_id}" -API_BY_KEY_PATH: Final = "/by-key/{key}" -API_RESOLVE_PATH: Final = "/resolve/{key}" -API_SYSTEM_PATH: Final = "/system/{key}" -API_TENANT_PATH: Final = "/tenant/{scope_id}/{key}" -API_USER_PATH: Final = "/user/{scope_id}/{key}" -# The active tenant's own overrides (#382). The tenant comes from -# ``request.state.tenant_id`` — never from the URL — so "current" is a literal -# segment, registered ahead of ``API_TENANT_PATH`` so it is not read as an id. -API_TENANT_CURRENT_PATH: Final = "/tenant/current" -API_TENANT_CURRENT_KEY_PATH: Final = "/tenant/current/{key}" - -# ── Menu ───────────────────────────────────────────────────────────── -MENU_LABEL: Final = MODULE_NAME -# Trailing slash: the browse route is registered at "/" under VIEW_PREFIX, so -# linking to the bare prefix costs a 307 round trip on every navigation. -MENU_URL: Final = f"{VIEW_PREFIX}/" -MENU_ICON: Final = "settings" -MENU_ORDER: Final = 200 - -# ── Permissions ────────────────────────────────────────────────────── -PERM_GROUP: Final = MODULE_NAME -PERM_VIEW: Final = "settings.view" -PERM_CREATE: Final = "settings.create" -PERM_EDIT: Final = "settings.edit" -PERM_DELETE: Final = "settings.delete" -# Edit the *active* tenant's overrides of ``tenant_overridable`` keys — and -# nothing else. Mapped onto ``tenant:owner`` / ``tenant:admin``; the four -# above stay platform-operator permissions (system scope, any tenant's scope). -PERM_TENANT_EDIT: Final = "settings.tenant.edit" -ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE, PERM_TENANT_EDIT) - -# ── Cache invalidation ─────────────────────────────────────────────── -# Published (after commit) for every SYSTEM / TENANT write, keyed per -# (tenant, key) — see ``settings.contracts.invalidation``. Settings caches -# nothing itself; consumers holding per-tenant resolved values subscribe. -INVALIDATION_CHANNEL: Final = "settings.values" - -# ── Database ───────────────────────────────────────────────────────── -DB_SCHEMA: Final = MODULE_PACKAGE - -# ── Inertia page component names ───────────────────────────────────── -PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse" -PAGE_CREATE: Final = f"{MODULE_NAME}/Create" -PAGE_EDIT: Final = f"{MODULE_NAME}/Edit" -PAGE_MODULES_EDIT: Final = f"{MODULE_NAME}/ModulesEdit" - -# ── Inertia prop keys ──────────────────────────────────────────────── -PROP_SETTINGS: Final = "settings" -PROP_SETTING: Final = "setting" -PROP_MODULES: Final = "modules" -PROP_ERROR: Final = "error" -PROP_PAGINATION: Final = "pagination" -PROP_COUNTS: Final = "counts" -PROP_FILTERS: Final = "filters" -PROP_KNOWN_KEYS: Final = "known_keys" -PROP_TESTABLE: Final = "testable" - -# ── Store listing (scope tabs + search + paging) ───────────────────── -# The table is server-filtered: the scope tabs report counts the client can no -# longer compute once it holds a single page, and an install with a few -# thousand overrides must not ship all of them to render twenty. -SCOPE_ALL: Final = "all" -"""Sentinel for "no scope filter" — a tab value, never a stored scope.""" - -DEFAULT_PER_PAGE: Final = 20 -MAX_PER_PAGE: Final = 200 - -# ── User-facing error messages ─────────────────────────────────────── -ERR_SETTING_NOT_FOUND: Final = "Setting not found" -ERR_KEY_ALREADY_EXISTS: Final = "Setting key already exists" -ERR_SETTING_EXISTS: Final = "A setting with this scope and key already exists" -ERR_SYSTEM_SCOPE_NO_ID: Final = "system scope must not have a scope_id" -ERR_SCOPED_REQUIRES_ID: Final = "tenant/user scope requires a scope_id" -ERR_UNKNOWN_SCOPE: Final = "unknown scope" -ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type" -ERR_UNKNOWN_TENANT: Final = "Unknown tenant" -ERR_NO_ACTIVE_TENANT: Final = "No active organisation for this request" -ERR_MANAGED_KEY_DELETE: Final = "This setting is managed elsewhere; clear it at {clear_via}" -ERR_MODULE_KEY_EDIT: Final = "Module settings must be edited at {edit_via} (package: {package})" -ERR_NOT_TENANT_OVERRIDABLE: Final = "This setting cannot be changed per organisation" +import importlib +import sys -# ── HTTP ───────────────────────────────────────────────────────────── -STATUS_CREATED: Final = 201 -STATUS_NO_CONTENT: Final = 204 -STATUS_FORBIDDEN: Final = 403 -STATUS_NOT_FOUND: Final = 404 -STATUS_CONFLICT: Final = 409 -STATUS_UNPROCESSABLE: Final = 422 +from simple_module_core.deprecation import deprecated_module -# ── Query parameter names ──────────────────────────────────────────── -QP_USER_ID: Final = "user_id" -QP_TENANT_ID: Final = "tenant_id" -QP_SCOPE: Final = "scope" -QP_SCOPE_ID: Final = "scope_id" -QP_Q: Final = "q" -QP_PAGE: Final = "page" -QP_PER_PAGE: Final = "per_page" +deprecated_module("settings.constants", remove_in="0.0.46", use="settings.core.constants") +sys.modules[__name__] = importlib.import_module("settings.core.constants") diff --git a/modules/settings/settings/contracts/accessor.py b/modules/settings/settings/contracts/accessor.py index 3e9ca5aa4..4de569ecc 100644 --- a/modules/settings/settings/contracts/accessor.py +++ b/modules/settings/settings/contracts/accessor.py @@ -36,7 +36,7 @@ async def handler(settings: SettingsDep): ) if TYPE_CHECKING: - from settings.service import SettingService + from settings.core.service import SettingService class _Unset: diff --git a/modules/settings/settings/contracts/invalidation.py b/modules/settings/settings/contracts/invalidation.py index aadc4e647..654a3da37 100644 --- a/modules/settings/settings/contracts/invalidation.py +++ b/modules/settings/settings/contracts/invalidation.py @@ -14,7 +14,7 @@ from __future__ import annotations -from settings.constants import INVALIDATION_CHANNEL +from settings.core.constants import INVALIDATION_CHANNEL _SEP = "|" diff --git a/modules/settings/settings/contracts/registry.py b/modules/settings/settings/contracts/registry.py index a876c4e6c..c9e182690 100644 --- a/modules/settings/settings/contracts/registry.py +++ b/modules/settings/settings/contracts/registry.py @@ -29,8 +29,8 @@ def on_startup(self, app): from dataclasses import dataclass, field from typing import TYPE_CHECKING -from settings.constants import ERR_KEY_ALREADY_EXISTS from settings.contracts.schemas import SettingScope, SettingValueType +from settings.core.constants import ERR_KEY_ALREADY_EXISTS if TYPE_CHECKING: from starlette.requests import Request diff --git a/modules/settings/settings/contracts/schemas.py b/modules/settings/settings/contracts/schemas.py index 4251661f0..a91e2ab3e 100644 --- a/modules/settings/settings/contracts/schemas.py +++ b/modules/settings/settings/contracts/schemas.py @@ -24,7 +24,11 @@ ) from sqlmodel import Field, SQLModel -from settings.constants import ERR_SCOPED_REQUIRES_ID, ERR_SYSTEM_SCOPE_NO_ID, ERR_VALUE_MISMATCH +from settings.core.constants import ( + ERR_SCOPED_REQUIRES_ID, + ERR_SYSTEM_SCOPE_NO_ID, + ERR_VALUE_MISMATCH, +) _BOOL_LITERALS = frozenset( {"true", "false", "1", "0", "t", "f", "yes", "no", "y", "n", "on", "off"} diff --git a/modules/settings/settings/core/__init__.py b/modules/settings/settings/core/__init__.py new file mode 100644 index 000000000..328ce379c --- /dev/null +++ b/modules/settings/settings/core/__init__.py @@ -0,0 +1,6 @@ +"""What every settings feature builds on. + +Constants, the module's own settings, the app.state container, errors, env-var +prefixes, and the scoped key/value setting service with its listing, write, +masking, secret and invalidation helpers. +""" diff --git a/modules/settings/settings/_announce.py b/modules/settings/settings/core/_announce.py similarity index 95% rename from modules/settings/settings/_announce.py rename to modules/settings/settings/core/_announce.py index f2b5eabb9..8cd896cd9 100644 --- a/modules/settings/settings/_announce.py +++ b/modules/settings/settings/core/_announce.py @@ -11,8 +11,8 @@ from simple_module_db.callbacks import register_on_commit from simple_module_db.stored_settings import SCOPE_SYSTEM, SCOPE_TENANT -from settings.constants import INVALIDATION_CHANNEL from settings.contracts.invalidation import invalidation_key +from settings.core.constants import INVALIDATION_CHANNEL if TYPE_CHECKING: from simple_module_core.invalidation import InvalidationBus diff --git a/modules/settings/settings/_listing.py b/modules/settings/settings/core/_listing.py similarity index 97% rename from modules/settings/settings/_listing.py rename to modules/settings/settings/core/_listing.py index f1b368a58..aeba25222 100644 --- a/modules/settings/settings/_listing.py +++ b/modules/settings/settings/core/_listing.py @@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col -from settings._row_masking import out -from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, SCOPE_ALL from settings.contracts.schemas import SettingOut, SettingScope +from settings.core._row_masking import out +from settings.core.constants import ALL_SCOPES, DEFAULT_PER_PAGE, SCOPE_ALL class SettingListing: diff --git a/modules/settings/settings/_managed_keys.py b/modules/settings/settings/core/_managed_keys.py similarity index 95% rename from modules/settings/settings/_managed_keys.py rename to modules/settings/settings/core/_managed_keys.py index d66111dc2..856a9125a 100644 --- a/modules/settings/settings/_managed_keys.py +++ b/modules/settings/settings/core/_managed_keys.py @@ -22,10 +22,10 @@ from collections.abc import Awaitable, Callable -from settings.constants import ERR_MANAGED_KEY_DELETE, ERR_MODULE_KEY_EDIT from settings.contracts.registry import SettingsRegistry, clear_route from settings.contracts.schemas import SettingScope -from settings.exceptions import SettingsError +from settings.core.constants import ERR_MANAGED_KEY_DELETE, ERR_MODULE_KEY_EDIT +from settings.core.exceptions import SettingsError TenantIsLive = Callable[[str], Awaitable[bool]] diff --git a/modules/settings/settings/_row_masking.py b/modules/settings/settings/core/_row_masking.py similarity index 98% rename from modules/settings/settings/_row_masking.py rename to modules/settings/settings/core/_row_masking.py index 497778981..78071bb37 100644 --- a/modules/settings/settings/_row_masking.py +++ b/modules/settings/settings/core/_row_masking.py @@ -12,8 +12,8 @@ from simple_module_core.secret_fields import conceals_secret from simple_module_db.stored_settings import Setting -from settings.constants import SENSITIVE_PLACEHOLDER from settings.contracts.schemas import SettingOut +from settings.core.constants import SENSITIVE_PLACEHOLDER def is_masked(entity: Setting) -> bool: diff --git a/modules/settings/settings/_secrets.py b/modules/settings/settings/core/_secrets.py similarity index 100% rename from modules/settings/settings/_secrets.py rename to modules/settings/settings/core/_secrets.py diff --git a/modules/settings/settings/_unique_write.py b/modules/settings/settings/core/_unique_write.py similarity index 95% rename from modules/settings/settings/_unique_write.py rename to modules/settings/settings/core/_unique_write.py index de8fcbcfd..e93b5446a 100644 --- a/modules/settings/settings/_unique_write.py +++ b/modules/settings/settings/core/_unique_write.py @@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from settings.exceptions import SettingsError +from settings.core.exceptions import SettingsError class DuplicateSettingError(SettingsError): diff --git a/modules/settings/settings/core/constants.py b/modules/settings/settings/core/constants.py new file mode 100644 index 000000000..d299cbf80 --- /dev/null +++ b/modules/settings/settings/core/constants.py @@ -0,0 +1,160 @@ +"""Centralized constants for the Settings module. + +Keeps module-level strings (route prefixes, permission ids, table names, +menu metadata, error messages, field limits, env prefix, i18n namespace, +scope identifiers) in one place so nothing is duplicated in Python or +inline-literal'd at call sites. +""" + +from __future__ import annotations + +from typing import Final + +# Re-exported (#500): these now live with the table in the framework. +from simple_module_db.stored_settings import ( # noqa: F401 + ALL_VALUE_TYPES, + DESCRIPTION_MAX_LENGTH, + KEY_MAX_LENGTH, + SCOPE_ID_MAX_LENGTH, + SCOPE_MAX_LENGTH, + SCOPE_SYSTEM, + SCOPE_TENANT, + SCOPE_USER, + SYSTEM_SCOPE_ID, + TABLE_SETTING, + UQ_SCOPE_KEY, + VALUE_MAX_LENGTH, + VALUE_TYPE_BOOL, + VALUE_TYPE_FLOAT, + VALUE_TYPE_INT, + VALUE_TYPE_JSON, + VALUE_TYPE_MAX_LENGTH, + VALUE_TYPE_STRING, +) + +# ── Module identity ────────────────────────────────────────────────── +MODULE_NAME: Final = "Settings" +MODULE_PACKAGE: Final = "settings" +ENV_PREFIX: Final = "SM_SETTINGS_" +LOCALE_NAMESPACE: Final = MODULE_PACKAGE + +# ── Scopes ─────────────────────────────────────────────────────────── +# Scope, value-type, table and field-limit constants moved to +# ``simple_module_db.stored_settings`` with the table itself (#500); they are +# re-exported from the import block at the top of this file. +ALL_SCOPES: Final = (SCOPE_SYSTEM, SCOPE_TENANT, SCOPE_USER) + +# ── Secrets ────────────────────────────────────────────────────────── +# The sensitive-key allowlist lives in ``simple_module_core.secret_fields``. +SENSITIVE_PLACEHOLDER: Final = "********" + +# ── Routing ────────────────────────────────────────────────────────── +API_PREFIX: Final = "/api/settings" +VIEW_PREFIX: Final = "/admin/settings" +VIEW_CREATE_PATH: Final = "/create" +VIEW_EDIT_PATH: Final = "/{setting_id}/edit" +VIEW_MODULES_PATH: Final = "/modules" +"""Legacy path for the per-module forms. Those now live at the section root; +this redirects, so existing links and bookmarks keep working.""" + +VIEW_STORE_PATH: Final = "/store" +"""Raw key/value store. Demoted from the section root: it is a database view, +and an admin looking for "settings" almost always wants the module forms.""" +API_BY_ID_PATH: Final = "/{setting_id}" +API_BY_KEY_PATH: Final = "/by-key/{key}" +API_RESOLVE_PATH: Final = "/resolve/{key}" +API_SYSTEM_PATH: Final = "/system/{key}" +API_TENANT_PATH: Final = "/tenant/{scope_id}/{key}" +API_USER_PATH: Final = "/user/{scope_id}/{key}" +# The active tenant's own overrides (#382). The tenant comes from +# ``request.state.tenant_id`` — never from the URL — so "current" is a literal +# segment, registered ahead of ``API_TENANT_PATH`` so it is not read as an id. +API_TENANT_CURRENT_PATH: Final = "/tenant/current" +API_TENANT_CURRENT_KEY_PATH: Final = "/tenant/current/{key}" + +# ── Menu ───────────────────────────────────────────────────────────── +MENU_LABEL: Final = MODULE_NAME +# Trailing slash: the browse route is registered at "/" under VIEW_PREFIX, so +# linking to the bare prefix costs a 307 round trip on every navigation. +MENU_URL: Final = f"{VIEW_PREFIX}/" +MENU_ICON: Final = "settings" +MENU_ORDER: Final = 200 + +# ── Permissions ────────────────────────────────────────────────────── +PERM_GROUP: Final = MODULE_NAME +PERM_VIEW: Final = "settings.view" +PERM_CREATE: Final = "settings.create" +PERM_EDIT: Final = "settings.edit" +PERM_DELETE: Final = "settings.delete" +# Edit the *active* tenant's overrides of ``tenant_overridable`` keys — and +# nothing else. Mapped onto ``tenant:owner`` / ``tenant:admin``; the four +# above stay platform-operator permissions (system scope, any tenant's scope). +PERM_TENANT_EDIT: Final = "settings.tenant.edit" +ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE, PERM_TENANT_EDIT) + +# ── Cache invalidation ─────────────────────────────────────────────── +# Published (after commit) for every SYSTEM / TENANT write, keyed per +# (tenant, key) — see ``settings.contracts.invalidation``. Settings caches +# nothing itself; consumers holding per-tenant resolved values subscribe. +INVALIDATION_CHANNEL: Final = "settings.values" + +# ── Database ───────────────────────────────────────────────────────── +DB_SCHEMA: Final = MODULE_PACKAGE + +# ── Inertia page component names ───────────────────────────────────── +PAGE_BROWSE: Final = f"{MODULE_NAME}/Browse" +PAGE_CREATE: Final = f"{MODULE_NAME}/Create" +PAGE_EDIT: Final = f"{MODULE_NAME}/Edit" +PAGE_MODULES_EDIT: Final = f"{MODULE_NAME}/ModulesEdit" + +# ── Inertia prop keys ──────────────────────────────────────────────── +PROP_SETTINGS: Final = "settings" +PROP_SETTING: Final = "setting" +PROP_MODULES: Final = "modules" +PROP_ERROR: Final = "error" +PROP_PAGINATION: Final = "pagination" +PROP_COUNTS: Final = "counts" +PROP_FILTERS: Final = "filters" +PROP_KNOWN_KEYS: Final = "known_keys" +PROP_TESTABLE: Final = "testable" + +# ── Store listing (scope tabs + search + paging) ───────────────────── +# The table is server-filtered: the scope tabs report counts the client can no +# longer compute once it holds a single page, and an install with a few +# thousand overrides must not ship all of them to render twenty. +SCOPE_ALL: Final = "all" +"""Sentinel for "no scope filter" — a tab value, never a stored scope.""" + +DEFAULT_PER_PAGE: Final = 20 +MAX_PER_PAGE: Final = 200 + +# ── User-facing error messages ─────────────────────────────────────── +ERR_SETTING_NOT_FOUND: Final = "Setting not found" +ERR_KEY_ALREADY_EXISTS: Final = "Setting key already exists" +ERR_SETTING_EXISTS: Final = "A setting with this scope and key already exists" +ERR_SYSTEM_SCOPE_NO_ID: Final = "system scope must not have a scope_id" +ERR_SCOPED_REQUIRES_ID: Final = "tenant/user scope requires a scope_id" +ERR_UNKNOWN_SCOPE: Final = "unknown scope" +ERR_VALUE_MISMATCH: Final = "value does not parse as declared value_type" +ERR_UNKNOWN_TENANT: Final = "Unknown tenant" +ERR_NO_ACTIVE_TENANT: Final = "No active organisation for this request" +ERR_MANAGED_KEY_DELETE: Final = "This setting is managed elsewhere; clear it at {clear_via}" +ERR_MODULE_KEY_EDIT: Final = "Module settings must be edited at {edit_via} (package: {package})" +ERR_NOT_TENANT_OVERRIDABLE: Final = "This setting cannot be changed per organisation" + +# ── HTTP ───────────────────────────────────────────────────────────── +STATUS_CREATED: Final = 201 +STATUS_NO_CONTENT: Final = 204 +STATUS_FORBIDDEN: Final = 403 +STATUS_NOT_FOUND: Final = 404 +STATUS_CONFLICT: Final = 409 +STATUS_UNPROCESSABLE: Final = 422 + +# ── Query parameter names ──────────────────────────────────────────── +QP_USER_ID: Final = "user_id" +QP_TENANT_ID: Final = "tenant_id" +QP_SCOPE: Final = "scope" +QP_SCOPE_ID: Final = "scope_id" +QP_Q: Final = "q" +QP_PAGE: Final = "page" +QP_PER_PAGE: Final = "per_page" diff --git a/modules/settings/settings/env_vars.py b/modules/settings/settings/core/env_vars.py similarity index 100% rename from modules/settings/settings/env_vars.py rename to modules/settings/settings/core/env_vars.py diff --git a/modules/settings/settings/errors.py b/modules/settings/settings/core/errors.py similarity index 86% rename from modules/settings/settings/errors.py rename to modules/settings/settings/core/errors.py index c19ea2860..169d6b6c4 100644 --- a/modules/settings/settings/errors.py +++ b/modules/settings/settings/core/errors.py @@ -5,8 +5,8 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, Response -from settings._managed_keys import ManagedKeyError, ModuleKeyError -from settings.constants import STATUS_UNPROCESSABLE +from settings.core._managed_keys import ManagedKeyError, ModuleKeyError +from settings.core.constants import STATUS_UNPROCESSABLE async def _managed_key(request: Request, exc: Exception) -> Response: diff --git a/modules/settings/settings/exceptions.py b/modules/settings/settings/core/exceptions.py similarity index 100% rename from modules/settings/settings/exceptions.py rename to modules/settings/settings/core/exceptions.py diff --git a/modules/settings/settings/invalidation.py b/modules/settings/settings/core/invalidation.py similarity index 96% rename from modules/settings/settings/invalidation.py rename to modules/settings/settings/core/invalidation.py index 08d281eb5..a2f2b72d4 100644 --- a/modules/settings/settings/invalidation.py +++ b/modules/settings/settings/core/invalidation.py @@ -12,7 +12,7 @@ from simple_module_db.stored_settings import settings_table_exists from settings.contracts.events import SettingsReloaded -from settings.store import SettingsStore +from settings.module_settings.store import SettingsStore CHANNEL = "settings.modules" logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def committed() -> None: def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh: - from settings.service import SettingService + from settings.core.service import SettingService async def reload(key: str | None) -> None: async with app.state.sm.db.session_factory() as db: diff --git a/modules/settings/settings/core/service.py b/modules/settings/settings/core/service.py new file mode 100644 index 000000000..d30a960b9 --- /dev/null +++ b/modules/settings/settings/core/service.py @@ -0,0 +1,237 @@ +"""Setting service implementation — scoped key/value CRUD + resolution.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from simple_module_db.stored_settings import SYSTEM_SCOPE_ID, VALUE_TYPE_STRING, Setting +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from settings.contracts.schemas import ( + SettingCreate, + SettingOut, + SettingScope, + SettingUpdate, + SettingUpsert, +) +from settings.core._announce import announce +from settings.core._listing import SettingListing +from settings.core._managed_keys import ModuleKeyError, TenantIsLive, ensure_deletable +from settings.core._row_masking import drop_placeholder_write, is_placeholder_write, out +from settings.core._unique_write import DuplicateSettingError, insert_if_free +from settings.core.constants import VIEW_PREFIX + +if TYPE_CHECKING: + from simple_module_core.invalidation import InvalidationBus + from simple_module_core.module_settings import ModuleSettingsRegistry + + from settings.contracts.registry import SettingsRegistry + + +class SettingService(SettingListing): + """Async CRUD + scope resolution for key/value settings. + + Resolution precedence when calling ``resolve`` / ``get_resolved_value``: + USER > TENANT > SYSTEM. The first match in that chain is returned. + """ + + def __init__( + self, + db: AsyncSession, + invalidation: InvalidationBus | None = None, + registry: SettingsRegistry | None = None, + tenant_is_live: TenantIsLive | None = None, + ) -> None: + self.db = db + self.module_registry: ModuleSettingsRegistry | None = None + # With a registry, ``delete``/``delete_scoped`` refuse a ``clear_via`` + # key (see ``_managed_keys``); ``tenant_is_live`` lets a deleted + # tenant's leftover row through. + self.registry = registry + self.tenant_is_live = tenant_is_live + # When given, SYSTEM/TENANT writes announce themselves after commit so + # per-tenant caches elsewhere drop the (tenant, key) they hold. + self.invalidation = invalidation + + def _changed(self, entity: Setting) -> None: + announce(self.db, self.invalidation, entity.scope, entity.scope_id, entity.key) + + def _ensure_generic_key(self, scope: SettingScope | str, key: str) -> None: + """Refuse a system row for a module field: only its editor validates and reloads. + + Tenant (and user) rows stay writable here: a ``tenant_overridable`` + module key's per-tenant override has no other platform route (#690). + """ + if SettingScope(scope) is not SettingScope.SYSTEM: + return + package, sep, field = key.partition(".") + registry = self.module_registry + settings_cls = registry.get(package) if sep and registry is not None else None + # An orphaned row (no such field any more) has no editor to clear it, so + # it stays deletable here. + if settings_cls is not None and field in settings_cls.model_fields: + raise ModuleKeyError(package, registry.manage_url(package) or f"{VIEW_PREFIX}/") + + # ── Lookup ────────────────────────────────────────────────────── + + async def get_by_id(self, setting_id: int) -> SettingOut | None: + entity = await self.db.get(Setting, setting_id) + if entity is None: + return None + return out(entity) + + async def get_scoped(self, scope: SettingScope, scope_id: str, key: str) -> SettingOut | None: + entity = await self._find(scope, scope_id, key) + return out(entity) if entity is not None else None + + async def _resolve_entity( + self, + key: str, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> Setting | None: + """First match walking USER > TENANT > SYSTEM, unserialized.""" + if user_id: + entity = await self._find(SettingScope.USER, user_id, key) + if entity is not None: + return entity + if tenant_id: + entity = await self._find(SettingScope.TENANT, tenant_id, key) + if entity is not None: + return entity + return await self._find(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key) + + async def resolve( + self, + key: str, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> SettingOut | None: + """The resolved row as the API returns it — masked if it is a secret.""" + entity = await self._resolve_entity(key, user_id=user_id, tenant_id=tenant_id) + return out(entity) if entity is not None else None + + async def get_resolved_value( + self, + key: str, + user_id: str | None = None, + tenant_id: str | None = None, + default: str | None = None, + ) -> str | None: + """The resolved *value*, unmasked — this is what consumers act on. + + ``SettingsAccessor`` is the read path other modules use to get a value + they are about to use, not one they are about to render. Masking here + would hand a module the placeholder instead of its own API key. The + masked view of the same row is :meth:`resolve`, which is what the API + returns. + """ + entity = await self._resolve_entity(key, user_id=user_id, tenant_id=tenant_id) + return entity.value if entity is not None else default + + # ── Mutations ─────────────────────────────────────────────────── + + async def create(self, data: SettingCreate) -> SettingOut: + self._ensure_generic_key(data.scope, data.key) + entity = Setting(**data.model_dump()) + if not await insert_if_free(self.db, entity): + raise DuplicateSettingError(f"{data.scope.value}/{data.scope_id}/{data.key}") + await self.db.refresh(entity) + self._changed(entity) + return out(entity) + + async def update(self, setting_id: int, data: SettingUpdate) -> SettingOut | None: + entity = await self.db.get(Setting, setting_id) + if entity is None: + return None + self._ensure_generic_key(entity.scope, entity.key) + changes = data.model_dump(exclude_unset=True) + drop_placeholder_write(entity, changes) + for field, value in changes.items(): + setattr(entity, field, value) + await self.db.flush() + await self.db.refresh(entity) + self._changed(entity) + return out(entity) + + async def upsert_scoped( + self, + scope: SettingScope, + scope_id: str, + key: str, + data: SettingUpsert, + ) -> SettingOut: + self._ensure_generic_key(scope, key) + entity = await self._find(scope, scope_id, key) + if entity is None: + entity = Setting( + scope=scope.value, + scope_id=scope_id, + key=key, + value=data.value, + value_type=( + data.value_type.value if data.value_type is not None else VALUE_TYPE_STRING + ), + description=data.description, + ) + # Two first-time writers can both get here; the loser's insert is + # refused by the unique key, and it becomes an update of the winner's row. + if await insert_if_free(self.db, entity): + await self.db.refresh(entity) + self._changed(entity) + return out(entity) + entity = await self._find(scope, scope_id, key) + if entity is None: # deleted again between the two statements + raise DuplicateSettingError(f"{scope.value}/{scope_id}/{key}") + if not is_placeholder_write(entity, data.value): + entity.value = data.value + if data.value_type is not None: + entity.value_type = data.value_type.value + # Honor explicit description=None as "clear"; skip only when unset. + if "description" in data.model_fields_set: + entity.description = data.description + await self.db.flush() + await self.db.refresh(entity) + self._changed(entity) + return out(entity) + + async def delete(self, setting_id: int, *, as_owner: bool = False) -> bool: + entity = await self.db.get(Setting, setting_id) + if entity is None: + return False + await self._remove(entity, as_owner) + return True + + async def delete_scoped( + self, scope: SettingScope, scope_id: str, key: str, *, as_owner: bool = False + ) -> bool: + """Delete one row; ``as_owner`` is for the module that reaps a ``clear_via`` key's file.""" + entity = await self._find(scope, scope_id, key) + if entity is None: + return False + await self._remove(entity, as_owner) + return True + + async def _remove(self, entity: Setting, as_owner: bool) -> None: + if not as_owner: + # A managed key's own clear route is the more specific refusal. + await ensure_deletable( + self.registry, self.tenant_is_live, entity.scope, entity.scope_id, entity.key + ) + self._ensure_generic_key(entity.scope, entity.key) + self._changed(entity) + await self.db.delete(entity) + await self.db.flush() + + # ── Internals ─────────────────────────────────────────────────── + + async def _find(self, scope: SettingScope, scope_id: str, key: str) -> Setting | None: + stmt = select(Setting).where( + col(Setting.scope) == scope.value, + col(Setting.scope_id) == scope_id, + col(Setting.key) == key, + ) + result = await self.db.execute(stmt) + return result.scalar_one_or_none() diff --git a/modules/settings/settings/services.py b/modules/settings/settings/core/services.py similarity index 95% rename from modules/settings/settings/services.py rename to modules/settings/settings/core/services.py index 97b91522d..536abf274 100644 --- a/modules/settings/settings/services.py +++ b/modules/settings/settings/core/services.py @@ -15,7 +15,7 @@ from simple_module_core.module_settings import ModuleSettingsRegistry from settings.contracts.registry import SettingsRegistry -from settings.settings import SettingsSettings +from settings.core.settings import SettingsSettings @dataclass diff --git a/modules/settings/settings/settings.py b/modules/settings/settings/core/settings.py similarity index 100% rename from modules/settings/settings/settings.py rename to modules/settings/settings/core/settings.py diff --git a/modules/settings/settings/core/tenant_scope.py b/modules/settings/settings/core/tenant_scope.py new file mode 100644 index 000000000..8d7e1ac21 --- /dev/null +++ b/modules/settings/settings/core/tenant_scope.py @@ -0,0 +1,130 @@ +"""Guards for TENANT-scope writes (#382). + +Two surfaces write tenant-scope rows, and they trust different things: + +* the **platform** routes (``/api/settings/tenant/{scope_id}/…``, guarded by + ``settings.*``) take the tenant from the URL, because a platform operator + legitimately edits any tenant. The id must name a real tenant + (:func:`require_known_tenant`) — a typo used to create a row no tenant would + ever read. +* the **self-service** routes (``/api/settings/tenant/current/…``, guarded by + ``settings.tenant.edit``) take the tenant from ``request.state.tenant_id`` + only (:func:`active_tenant`), and only for keys declared + ``tenant_overridable`` (:func:`overridable_definition`). + +A key with a ``clear_via`` is protected from generic deletes by +``SettingService`` itself (``_managed_keys``), not by anything here. + +Both run the definition's ``check`` (:func:`run_check`) before writing. +""" + +from __future__ import annotations + +from fastapi import HTTPException, Request +from simple_module_core.tenancy import tenant_exists + +from settings.contracts.registry import SettingDefinition, SettingsRegistry +from settings.contracts.schemas import SettingOut, SettingScope +from settings.core.constants import ( + ERR_NO_ACTIVE_TENANT, + ERR_NOT_TENANT_OVERRIDABLE, + ERR_UNKNOWN_TENANT, + MODULE_PACKAGE, + STATUS_FORBIDDEN, + STATUS_NOT_FOUND, + STATUS_UNPROCESSABLE, +) + + +def registry_of(request: Request) -> SettingsRegistry | None: + services = getattr(request.app.state, MODULE_PACKAGE, None) + return getattr(services, "registry", None) + + +async def is_known_tenant(request: Request, tenant_id: str) -> bool: + """False only when a tenant-owning module says the id is unknown. + + With no such module installed (``tenant_exists`` answers ``None``) every id + is accepted, which is the pre-#382 behaviour single-tenant installs rely on. + """ + return await tenant_exists(request.app, tenant_id) is not False + + +async def require_known_tenant(request: Request, tenant_id: str) -> None: + if not await is_known_tenant(request, tenant_id): + raise HTTPException(status_code=STATUS_NOT_FOUND, detail=ERR_UNKNOWN_TENANT) + + +def active_tenant(request: Request) -> str: + """The request's resolved tenant; 403 when it acts for none.""" + tenant_id = getattr(request.state, "tenant_id", None) + if not tenant_id: + raise HTTPException(status_code=STATUS_FORBIDDEN, detail=ERR_NO_ACTIVE_TENANT) + return tenant_id + + +def overridable_definition(request: Request, key: str) -> SettingDefinition: + """The key's definition when a tenant may override it; 422 otherwise.""" + registry = registry_of(request) + definition = registry.get(key) if registry is not None else None + if definition is None or not definition.tenant_overridable: + raise HTTPException(status_code=STATUS_UNPROCESSABLE, detail=ERR_NOT_TENANT_OVERRIDABLE) + return definition + + +async def run_check(request: Request, tenant_id: str, key: str, value: str) -> None: + """Run the key's declared ``check`` for a write at ``tenant_id``, if any.""" + registry = registry_of(request) + definition = registry.get(key) if registry is not None else None + if definition is None or definition.check is None: + return + try: + await definition.check(request, tenant_id, value) + except LookupError as exc: + raise HTTPException(status_code=STATUS_NOT_FOUND, detail=str(exc) or key) from exc + except ValueError as exc: + raise HTTPException(status_code=STATUS_UNPROCESSABLE, detail=str(exc) or key) from exc + + +async def tenant_write_error(request: Request, tenant_id: str, key: str, value: str) -> str | None: + """The reason a platform form may not write ``key`` at ``tenant_id``, or ``None``. + + For the Inertia store form, which reports errors on the field rather than + as an HTTP status. + """ + if not await is_known_tenant(request, tenant_id): + return ERR_UNKNOWN_TENANT + try: + await run_check(request, tenant_id, key, value) + except HTTPException as exc: + return str(exc.detail) + return None + + +async def tenant_update_error( + request: Request, current: SettingOut | None, value: str | None +) -> str | None: + """:func:`tenant_write_error` for the edit form (``PUT /settings/{id}``). + + The row's own scope, tenant and key decide — an update cannot move a row. + Only a TENANT row whose value actually changes is checked: a + description-only edit, or the masked echo of an unchanged secret, writes + nothing the check could object to. + """ + if current is None or current.scope != SettingScope.TENANT: + return None + if value is None or value == current.value: + return None + return await tenant_write_error(request, current.scope_id, current.key, value) + + +__all__ = [ + "active_tenant", + "is_known_tenant", + "overridable_definition", + "registry_of", + "require_known_tenant", + "run_check", + "tenant_update_error", + "tenant_write_error", +] diff --git a/modules/settings/settings/core/tenant_view.py b/modules/settings/settings/core/tenant_view.py new file mode 100644 index 000000000..e5ac65410 --- /dev/null +++ b/modules/settings/settings/core/tenant_view.py @@ -0,0 +1,85 @@ +"""What a tenant sees of its overridable settings (#382). + +One row per ``tenant_overridable`` definition: the value the tenant inherits +(system override, else the declared default), its own override if any, and the +effective value — the same precedence ``SettingsAccessor`` applies at runtime. +Used by the self-service list endpoint and by the tenant settings page. +""" + +from __future__ import annotations + +from simple_module_core.secret_fields import conceals_secret, mask +from simple_module_db.stored_settings import SYSTEM_SCOPE_ID +from sqlmodel import SQLModel + +from settings.contracts.registry import SettingsRegistry +from settings.contracts.schemas import SettingScope, SettingValueType +from settings.core.service import SettingService + + +class TenantSettingView(SQLModel): + """One overridable key, as the active tenant sees it.""" + + key: str + description: str + value_type: SettingValueType + inherited: str + """What applies without a tenant override: the system value, else the default.""" + value: str | None + """The tenant's own override, ``None`` while it inherits.""" + effective: str + upload_url: str = "" + """Set for a file-id key: upload here (POST) / clear here (DELETE).""" + description_key: str = "" + """i18n key for ``description``; the client falls back to ``description``.""" + label_key: str = "" + """i18n key for the row title; the client falls back to ``key``.""" + + +def _shown(key: str, value: str | None, value_type: str) -> str | None: + if value is None: + return None + return mask(value) if conceals_secret(key, value, value_type) else value + + +async def list_for_tenant( + service: SettingService, registry: SettingsRegistry | None, tenant_id: str +) -> list[TenantSettingView]: + if registry is None: + return [] + definitions = registry.tenant_overridable + if not definitions: + return [] + keys = {d.key for d in definitions} + system = { + row.key: row.value + for row in await service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) + if row.key in keys + } + own = { + row.key: row.value + for row in await service.list_by_scope_unmasked(SettingScope.TENANT, tenant_id) + if row.key in keys + } + views = [] + for d in definitions: + inherited = system.get(d.key, d.default) + value = own.get(d.key) + views.append( + TenantSettingView( + key=d.key, + description=d.description, + value_type=d.value_type, + inherited=_shown(d.key, inherited, d.value_type) or "", + value=_shown(d.key, value, d.value_type), + effective=_shown(d.key, value if value is not None else inherited, d.value_type) + or "", + upload_url=d.upload_url, + description_key=d.description_key, + label_key=d.label_key, + ) + ) + return views + + +__all__ = ["TenantSettingView", "list_for_tenant"] diff --git a/modules/settings/settings/deps.py b/modules/settings/settings/deps.py index a561ec9cf..eb6cfbb44 100644 --- a/modules/settings/settings/deps.py +++ b/modules/settings/settings/deps.py @@ -1,76 +1,11 @@ -"""FastAPI dependencies for the Settings module. - -Consumers in other modules should almost always depend on ``SettingsDep`` -(the accessor) rather than ``SettingService`` directly — the accessor is -bound to the current request's user/tenant so ``get_bool(key)`` etc. just -work. -""" +"""Moved to :mod:`settings.web.deps`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from typing import Annotated - -from fastapi import Depends, Request -from simple_module_core.app_state import get_services -from simple_module_core.module_settings import get_module_settings_registry -from simple_module_db.deps import get_db -from sqlalchemy.ext.asyncio import AsyncSession - -from settings.constants import MODULE_PACKAGE -from settings.contracts.accessor import SettingsAccessor -from settings.contracts.registry import SettingsRegistry -from settings.service import SettingService -from settings.tenant_scope import is_known_tenant, registry_of - - -async def get_setting_service( - request: Request, - db: AsyncSession = Depends(get_db), -) -> SettingService: - bus = getattr(get_services(request.app), "invalidation", None) - - async def tenant_is_live(tenant_id: str) -> bool: - return await is_known_tenant(request, tenant_id) - - return SettingService( - db, invalidation=bus, registry=registry_of(request), tenant_is_live=tenant_is_live - ) - - -async def get_generic_setting_service( - request: Request, - service: SettingService = Depends(get_setting_service), -) -> SettingService: - """Raw store routes must send module configuration through its own editor.""" - service.module_registry = get_module_settings_registry(request.app) - return service - - -def get_settings_registry(request: Request) -> SettingsRegistry: - """Return the app-wide settings registry populated during boot.""" - services = getattr(request.app.state, MODULE_PACKAGE) - return services.registry - - -async def get_settings_accessor( - request: Request, - service: SettingService = Depends(get_setting_service), - registry: SettingsRegistry = Depends(get_settings_registry), -) -> SettingsAccessor: - """Build a request-scoped accessor bound to the caller's user/tenant. - - ``request.state.user`` is populated by ``users.middleware`` (carries a - ``UserContext``). ``request.state.tenant_id`` is populated by - ``TenantMiddleware`` when ``multi_tenant=True``. Both are optional — - the accessor gracefully degrades to lower scopes (or the registered - default) if the request is unauthenticated or untenanted. - """ - user = getattr(request.state, "user", None) - user_id = str(user.id) if user is not None else None - tenant_id = getattr(request.state, "tenant_id", None) - return SettingsAccessor(service, registry, user_id=user_id, tenant_id=tenant_id) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -# Convenience aliases for typing annotations in consumer modules. -SettingServiceDep = Annotated[SettingService, Depends(get_setting_service)] -SettingsDep = Annotated[SettingsAccessor, Depends(get_settings_accessor)] +deprecated_module("settings.deps", remove_in="0.0.46", use="settings.web.deps") +sys.modules[__name__] = importlib.import_module("settings.web.deps") diff --git a/modules/settings/settings/endpoints/__init__.py b/modules/settings/settings/endpoints/__init__.py index e69de29bb..14158e1cc 100644 --- a/modules/settings/settings/endpoints/__init__.py +++ b/modules/settings/settings/endpoints/__init__.py @@ -0,0 +1 @@ +"""Legacy import path; the endpoint modules live in :mod:`settings.web.endpoints`.""" diff --git a/modules/settings/settings/endpoints/module_api.py b/modules/settings/settings/endpoints/module_api.py index 4b74d38ca..35dcbf299 100644 --- a/modules/settings/settings/endpoints/module_api.py +++ b/modules/settings/settings/endpoints/module_api.py @@ -1,166 +1,13 @@ -"""REST endpoints for the per-module settings admin UI. - -PUT drops fields whose value is the mask sentinel so the UI can echo back -masked secrets without clobbering the real value. -""" +"""Moved to :mod:`settings.web.endpoints.module_api`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import json -from typing import Any +import importlib +import sys -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status -from pydantic import ValidationError -from simple_module_core.module_settings import module_settings_registry -from simple_module_hosting.i18n_deps import TranslatorDep -from simple_module_hosting.permissions import RequiresPermission +from simple_module_core.deprecation import deprecated_module -from settings._module_settings import ( - SECRET_MASK, - collect_module_settings, - overrides_by_package, +deprecated_module( + "settings.endpoints.module_api", remove_in="0.0.46", use="settings.web.endpoints.module_api" ) -from settings._module_settings_props import serialize -from settings.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW -from settings.deps import get_setting_service -from settings.invalidation import reload_on_commit -from settings.reload import EnvPinnedError, apply_changes_and_reload -from settings.service import SettingService -from settings.store import SettingsStore - -router = APIRouter(prefix="/modules", tags=["Settings Modules"]) - -# Per-module settings UI exposes raw secret values (mailer password, JWT -# signing keys, etc.) — every endpoint here is gated on the same permissions -# the scoped API uses so a non-admin can't read or mutate module config. -_VIEW = [Depends(RequiresPermission(PERM_VIEW))] -_EDIT = [Depends(RequiresPermission(PERM_EDIT))] -_DELETE = [Depends(RequiresPermission(PERM_DELETE))] - - -def _masked_fields(app: FastAPI, package: str) -> frozenset[str]: - """The fields this package's editor rendered as dots. - - ``ModuleSettingField.is_secret`` is the same flag that drove the input type - and the reveal affordance, so it answers exactly the question here: which - submitted values could be an echo of the mask rather than a real edit. - """ - return frozenset( - field.name - for view in collect_module_settings(app) - if view.package == package - for field in view.fields - if field.is_secret - ) - - -def _strip_mask_sentinels(masked: frozenset[str], changes: dict[str, Any]) -> dict[str, Any]: - """Drop the mask sentinel on the fields that were rendered masked. - - Not on ``is_secret_field(name)``: a value can be masked because it *is* a - credential (a DSN with a password in it) on a field whose name says nothing - of the sort, and storing the row of dots the editor rendered would overwrite - the real connection string. - - Not on the sentinel alone either, which is where this started — that silently - dropped the write on any field whose real value happened to equal - :data:`SECRET_MASK`, with no error and no saved row to show for it. - """ - return { - name: value - for name, value in changes.items() - if not (name in masked and isinstance(value, str) and value == SECRET_MASK) - } - - -@router.get("", dependencies=_VIEW) -async def list_modules( - request: Request, - service: SettingService = Depends(get_setting_service), -) -> dict[str, Any]: - # Match the Inertia ModulesEdit view: without the overrides map, every - # field's `source`/`db_override` would report env/default even when a - # stored override is actually in force. - overrides = await overrides_by_package(service) - views = collect_module_settings(request.app, overrides) - return {"modules": serialize(views)} - - -@router.put("/{package}", dependencies=_EDIT) -async def update_module( - package: str, - changes: dict[str, Any], - request: Request, - t: TranslatorDep, - service: SettingService = Depends(get_setting_service), -) -> dict[str, Any]: - registry = module_settings_registry(request.app) - if registry.get(package) is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package") - if registry.manage_url(package) is not None: - # Modules with a purpose-built settings screen (e.g. Branding) own - # their own validation/preview flow — the generic editor only ever - # links out to it (ModulesEdit.tsx), it must not also accept writes - # that bypass that flow. - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="This module has a dedicated settings page; edit it there instead.", - ) - - cleaned = _strip_mask_sentinels(_masked_fields(request.app, package), changes) - if not cleaned: - return {"ok": True, "changed": []} - - store = SettingsStore(service) - bus = request.app.state.sm.event_bus - try: - await apply_changes_and_reload(request.app, bus, store, package=package, changes=cleaned) - except ValidationError as exc: - # ``exc.errors()`` still leaves ``ctx.error`` holding the raw Python - # exception (not JSON-serializable). Round-trip through pydantic's - # own JSON encoder to get clean, serializable errors. ``input`` is - # dropped: it echoes the submitted value, which may be a secret. - clean = json.loads(exc.json(include_url=False, include_input=False)) - raise HTTPException(status_code=422, detail=clean) from exc - except EnvPinnedError as exc: - # Shaped like a pydantic 422 so the editor shows it on the field. - detail = [ - { - "loc": ["body", name], - "msg": t.t("settings.modules_form.env_pinned"), - "type": "env_pinned", - } - for name in exc.fields - ] - raise HTTPException(status_code=422, detail=detail) from exc - except KeyError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - return {"ok": True, "changed": sorted(cleaned)} - - -@router.delete("/{package}/{field}", status_code=status.HTTP_204_NO_CONTENT, dependencies=_DELETE) -async def clear_module_field( - package: str, - field: str, - request: Request, - service: SettingService = Depends(get_setting_service), -) -> Response: - registry = module_settings_registry(request.app) - cls = registry.get(package) - if cls is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package") - if registry.manage_url(package) is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="This module has a dedicated settings page; edit it there instead.", - ) - if field not in cls.model_fields: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown field") - - store = SettingsStore(service) - await store.clear_override(package, field) - - reload_on_commit(request.app, request.app.state.sm.event_bus, store, package) - - return Response(status_code=status.HTTP_204_NO_CONTENT) +sys.modules[__name__] = importlib.import_module("settings.web.endpoints.module_api") diff --git a/modules/settings/settings/module.py b/modules/settings/settings/module.py index cf149c22f..fe43b5e5f 100644 --- a/modules/settings/settings/module.py +++ b/modules/settings/settings/module.py @@ -12,7 +12,7 @@ from simple_module_core.module import ModuleBase, ModuleMeta from simple_module_core.permissions import PermissionRegistry -from settings.constants import ( +from settings.core.constants import ( ALL_PERMISSIONS, API_PREFIX, LOCALE_NAMESPACE, @@ -65,8 +65,8 @@ def register_settings(self, app: FastAPI) -> None: from simple_module_core.module_settings import module_settings_registry from settings.contracts.registry import SettingsRegistry - from settings.services import SettingsServices - from settings.settings import SettingsSettings + from settings.core.services import SettingsServices + from settings.core.settings import SettingsSettings # The framework owns the module-settings registry (#500); the admin # screens read the same object through ``services.module_registry``. @@ -81,7 +81,7 @@ def register_settings(self, app: FastAPI) -> None: services.module_registry.register(MODULE_PACKAGE, SettingsSettings) def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: - from settings.invalidation import subscribe + from settings.core.invalidation import subscribe self._refresh = subscribe(bus, app) @@ -94,15 +94,15 @@ async def on_shutdown(self, app: FastAPI) -> None: await self._refresh.stop() def register_exception_handlers(self, app: FastAPI) -> None: - from settings.errors import install_exception_handlers + from settings.core.errors import install_exception_handlers install_exception_handlers(app) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from settings.endpoints.api import router as api - from settings.endpoints.module_api import router as module_api - from settings.endpoints.tenant_api import router as tenant_api - from settings.endpoints.views import router as views + from settings.web.endpoints.api import router as api + from settings.web.endpoints.module_api import router as module_api + from settings.web.endpoints.tenant_api import router as tenant_api + from settings.web.endpoints.views import router as views api_router.include_router(module_api) # Before ``api``: its ``/tenant/{scope_id}/{key}`` would capture diff --git a/modules/settings/settings/module_settings/__init__.py b/modules/settings/settings/module_settings/__init__.py new file mode 100644 index 000000000..a5dd774bb --- /dev/null +++ b/modules/settings/settings/module_settings/__init__.py @@ -0,0 +1,5 @@ +"""Per-module pydantic settings. + +Autodiscovery, field metadata, the SYSTEM-scope store, hot reload and the +editor's Inertia props. +""" diff --git a/modules/settings/settings/_field_meta.py b/modules/settings/settings/module_settings/_field_meta.py similarity index 100% rename from modules/settings/settings/_field_meta.py rename to modules/settings/settings/module_settings/_field_meta.py diff --git a/modules/settings/settings/_module_settings.py b/modules/settings/settings/module_settings/discovery.py similarity index 97% rename from modules/settings/settings/_module_settings.py rename to modules/settings/settings/module_settings/discovery.py index c40700fdc..105713a08 100644 --- a/modules/settings/settings/_module_settings.py +++ b/modules/settings/settings/module_settings/discovery.py @@ -31,9 +31,9 @@ value_type_for_field, ) -from settings._field_meta import choices_for, env_readable_var, resolve_default -from settings.env_vars import env_prefix_for -from settings.service import SettingService +from settings.core.env_vars import env_prefix_for +from settings.core.service import SettingService +from settings.module_settings._field_meta import choices_for, env_readable_var, resolve_default @dataclass(frozen=True, slots=True) @@ -278,6 +278,6 @@ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[s treats as "nothing overridden". Shared by both the Inertia view and the JSON API so the two never drift on what "overridden" means. """ - from settings.store import SettingsStore + from settings.module_settings.store import SettingsStore return await SettingsStore(service).all_override_fields() diff --git a/modules/settings/settings/_module_settings_props.py b/modules/settings/settings/module_settings/props.py similarity index 97% rename from modules/settings/settings/_module_settings_props.py rename to modules/settings/settings/module_settings/props.py index 0d971706f..321282fb7 100644 --- a/modules/settings/settings/_module_settings_props.py +++ b/modules/settings/settings/module_settings/props.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.encoders import jsonable_encoder -from settings._module_settings import ModuleSettingsView, _package_of +from settings.module_settings.discovery import ModuleSettingsView, _package_of def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]: diff --git a/modules/settings/settings/module_settings/reload.py b/modules/settings/settings/module_settings/reload.py new file mode 100644 index 000000000..b4495bf31 --- /dev/null +++ b/modules/settings/settings/module_settings/reload.py @@ -0,0 +1,162 @@ +"""Apply field changes to a module's settings, validate, persist, hot-swap.""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi import FastAPI +from pydantic import SecretBytes, SecretStr, ValidationError +from pydantic_core import to_jsonable_python +from pydantic_settings import BaseSettings +from simple_module_core.events import EventBus +from simple_module_core.module_settings import module_settings_registry +from simple_module_core.settings_hydrate import ( + env_supplied_fields, + hydrate_settings, + value_type_for_field, +) + +from settings.core.invalidation import reload_on_commit +from settings.module_settings.store import SettingsStore + + +class EnvPinnedError(ValueError): + """A change targets fields the environment sets, which always win (GH #486). + + Storing it anyway would hot-swap the value into this process only: every + restart and every other worker would hydrate the env value instead. + """ + + def __init__(self, fields: list[str]) -> None: + super().__init__(f"Set by the environment: {', '.join(fields)}") + self.fields = fields + + +def _encode(value: Any, value_type: str) -> str: + if value_type == "json": + return json.dumps(value) + return str(value) + + +def _unwrap_secrets(value: Any) -> Any: + """``value`` with every ``SecretStr``/``SecretBytes`` replaced by its secret. + + The store needs the real credential: a JSON dump masks secrets as + asterisks, also when they sit inside ``X | None`` or a container. + """ + if isinstance(value, SecretStr | SecretBytes): + return value.get_secret_value() + if isinstance(value, dict): + return {k: _unwrap_secrets(v) for k, v in value.items()} + if isinstance(value, list | tuple | set | frozenset): + return [_unwrap_secrets(v) for v in value] + return value + + +def _relocate(exc: ValidationError, field_name: str) -> ValidationError: + """Re-key a stored-value error (``loc=["value"]``) onto the field it was for. + + The write schema validates the encoded ``value`` column, which names no + settings field, so the editor could not show it anywhere. + """ + lines: list[Any] = [ + { + "type": e["type"], + "loc": (field_name, *e["loc"][1:]), + "input": e.get("input"), + "ctx": e.get("ctx", {}), + } + for e in exc.errors() + ] + return ValidationError.from_exception_data(exc.title, lines) + + +def _is_default(cls: type[BaseSettings], field_name: str, value: Any) -> bool: + """Whether the validated ``value`` is what the field defaults to.""" + info = cls.model_fields[field_name] + if info.is_required(): + return False + default = info.get_default(call_default_factory=True) + return to_jsonable_python(_unwrap_secrets(default)) == to_jsonable_python( + _unwrap_secrets(value) + ) + + +async def apply_changes_and_reload( + app: FastAPI, + bus: EventBus, + store: SettingsStore, + *, + package: str, + changes: dict[str, Any], +) -> BaseSettings: + """Validate and persist; reload and publish ``SettingsReloaded`` after commit.""" + registry = module_settings_registry(app) + cls = registry.get(package) + if cls is None: + raise KeyError(f"Unknown settings package: {package!r}") + + unknown = set(changes) - set(cls.model_fields) + if unknown: + raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}") + + # Include prior writes in this transaction without exposing them to other requests. + current = await hydrate_settings(cls, store, package, lenient=True) + # Compare against the *dumped* current value, not the attribute: a field + # typed as a list of models (branding's ``footer_links``) holds model + # instances, while ``changes`` carries the plain dicts a DTO dumps to. The + # attribute comparison never matched those, so an unchanged list was + # rewritten to the store on every save. + merged = current.model_dump() + diff = {k: v for k, v in changes.items() if merged.get(k) != v} + + # A row that already holds the default (stored before saving a default + # cleared the row) is not a change, so the diff never reaches it; without + # this the field stays "overridden in DB" however often it is saved. + unchanged = set(changes) - set(diff) - env_supplied_fields(cls) + if unchanged: + stored = await store.get_overrides(package) + for name in sorted(unchanged & set(stored)): + if _is_default(cls, name, merged.get(name)): + await store.clear_override(package, name) + + if not diff: + reload_on_commit(app, bus, store, package) + return current + + # Only a field that would actually change is refused: a client that echoes + # the whole GET payload back resubmits env-set fields at their live value. + pinned = sorted(set(diff) & env_supplied_fields(cls)) + if pinned: + raise EnvPinnedError(pinned) + + merged.update(diff) + validated = cls(**merged) + dumped_all = validated.model_dump() + + # A json field stores what validated, not what was sent: a field validator + # may have turned the editor's text into the declared type (``json.loads`` + # on a dict), and encoding the raw input stored a JSON *string* that + # hydration could not read back as that type (GH #487). Scalars keep the + # submitted value. Either way a serializer (``SecretStr`` dumps as + # asterisks) must never decide what is written to the store, so secrets + # are unwrapped before the validated value is made JSON-able. + for field_name, raw_value in diff.items(): + vtype = value_type_for_field(cls, field_name) + if _is_default(cls, field_name, dumped_all.get(field_name, raw_value)): + # Saving the default is clearing the override: a stored row would + # keep the field labelled "overridden in DB" while holding exactly + # what the code would have supplied anyway. + await store.clear_override(package, field_name) + continue + if vtype == "json": + dumped = validated.model_dump(include={field_name}).get(field_name, raw_value) + raw_value = to_jsonable_python(_unwrap_secrets(dumped)) + try: + await store.set_override(package, field_name, _encode(raw_value, vtype), vtype) + except ValidationError as exc: + raise _relocate(exc, field_name) from exc + + reload_on_commit(app, bus, store, package) + return validated diff --git a/modules/settings/settings/module_settings/store.py b/modules/settings/settings/module_settings/store.py new file mode 100644 index 000000000..674ba29dc --- /dev/null +++ b/modules/settings/settings/module_settings/store.py @@ -0,0 +1,92 @@ +"""DB-backed key/value store for module settings (SYSTEM scope). + +The read-only queries (``package_overrides``, ``get_overrides_sync``) moved to +``simple_module_db.stored_settings`` with the table (#500) and are re-exported +here. ``SettingsStore`` satisfies +``simple_module_core.settings_hydrate.OverrideSource``. + +Wraps the existing SettingService. Keys are namespaced ``.`` +to avoid collision with free-form user-defined setting keys. + +Reads are deliberately *unmasked*. This store is what applies overrides to the +live settings objects at boot, not what renders them: the masking on the +service's ordinary read path is for the admin screens, and going through it here +would write a row of dots over every real credential the deployment had stored. +The display side masks independently, from the settings object it is given — +see ``_module_settings._field_view``. +""" + +from __future__ import annotations + +from simple_module_db.callbacks import OnCommitCallback +from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500) + SYSTEM_SCOPE_ID, + get_overrides_sync, + package_overrides, +) + +from settings.contracts.schemas import SettingScope, SettingUpsert, SettingValueType +from settings.core.service import SettingService + + +def _key(package: str, field: str) -> str: + return f"{package}.{field}" + + +class SettingsStore: + """SYSTEM-scoped key/value store keyed by ``(package, field)``. + + Satisfies ``OverrideSource`` for :func:`hydrate_settings`. + """ + + def __init__(self, service: SettingService) -> None: + self._service = service + + def on_commit(self, callback: OnCommitCallback) -> None: + self._service.db.on_commit(callback) + + async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]: + """Return ``{field_name: (raw_value, value_type)}`` for a package.""" + items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) + return package_overrides(((i.key, i.value, i.value_type) for i in items), package) + + async def all_override_fields(self) -> dict[str, frozenset[str]]: + """Return ``{package: {field_name, ...}}`` for every stored override. + + One query for the whole screen. ``get_overrides`` re-reads the entire + SYSTEM scope per package, so calling it in a loop over the installed + modules is one full read per module for the same rows. + """ + items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) + out: dict[str, set[str]] = {} + for item in items: + package, sep, field_name = item.key.partition(".") + if not sep or not field_name or "." in field_name: + continue + out.setdefault(package, set()).add(field_name) + return {package: frozenset(fields) for package, fields in out.items()} + + async def set_override(self, package: str, field: str, value: str, value_type: str) -> None: + await self._service.upsert_scoped( + SettingScope.SYSTEM, + SYSTEM_SCOPE_ID, + _key(package, field), + SettingUpsert(value=value, value_type=SettingValueType(value_type)), + ) + + async def clear_override(self, package: str, field: str) -> None: + await self._service.delete_scoped( + SettingScope.SYSTEM, SYSTEM_SCOPE_ID, _key(package, field) + ) + + async def list_packages_with_overrides(self) -> list[str]: + items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) + pkgs: set[str] = set() + for item in items: + if "." not in item.key: + continue + pkg, rest = item.key.split(".", 1) + if "." in rest: + continue + pkgs.add(pkg) + return sorted(pkgs) diff --git a/modules/settings/settings/pages/Browse.tsx b/modules/settings/settings/pages/Browse.tsx index 1a60705b4..6e4c4ecad 100644 --- a/modules/settings/settings/pages/Browse.tsx +++ b/modules/settings/settings/pages/Browse.tsx @@ -9,9 +9,9 @@ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import { Plus, Search, Settings as SettingsIcon, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { type ScopeCounts, type ScopeFilter, ScopeTabs } from './components/ScopeTabs'; -import { StoreCards } from './components/StoreCards'; -import { StoreTable } from './components/StoreTable'; +import { type ScopeCounts, type ScopeFilter, ScopeTabs } from '../ui/components/ScopeTabs'; +import { StoreCards } from '../ui/components/StoreCards'; +import { StoreTable } from '../ui/components/StoreTable'; import { ROUTES } from './routes'; import type { Pagination, Setting } from './types'; diff --git a/modules/settings/settings/pages/Create.tsx b/modules/settings/settings/pages/Create.tsx index 4db26ed0e..2d1ca9145 100644 --- a/modules/settings/settings/pages/Create.tsx +++ b/modules/settings/settings/pages/Create.tsx @@ -15,10 +15,10 @@ import { import { Textarea } from '@simple-module-py/ui/components/ui/textarea'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import type React from 'react'; -import { FormLevelError } from './components/FormLevelError'; -import { KeyField, type KnownKey } from './components/KeyField'; -import { ResolvedValue } from './components/ResolvedValue'; -import ValueInput from './components/ValueInput'; +import { FormLevelError } from '../ui/components/FormLevelError'; +import { KeyField, type KnownKey } from '../ui/components/KeyField'; +import { ResolvedValue } from '../ui/components/ResolvedValue'; +import ValueInput from '../ui/components/ValueInput'; import { ROUTES } from './routes'; import { SCOPES, type SettingScope, VALUE_TYPES, type ValueType } from './types'; diff --git a/modules/settings/settings/pages/Edit.tsx b/modules/settings/settings/pages/Edit.tsx index 14f6c87ea..3b3910928 100644 --- a/modules/settings/settings/pages/Edit.tsx +++ b/modules/settings/settings/pages/Edit.tsx @@ -8,8 +8,8 @@ import { Label } from '@simple-module-py/ui/components/ui/label'; import { Textarea } from '@simple-module-py/ui/components/ui/textarea'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import type React from 'react'; -import { FormLevelError } from './components/FormLevelError'; -import ValueInput from './components/ValueInput'; +import { FormLevelError } from '../ui/components/FormLevelError'; +import ValueInput from '../ui/components/ValueInput'; import { ROUTES } from './routes'; import type { Setting } from './types'; diff --git a/modules/settings/settings/pages/ModulesEdit.tsx b/modules/settings/settings/pages/ModulesEdit.tsx index 0db34f4bb..1d8d141a9 100644 --- a/modules/settings/settings/pages/ModulesEdit.tsx +++ b/modules/settings/settings/pages/ModulesEdit.tsx @@ -6,7 +6,7 @@ import { Input } from '@simple-module-py/ui/components/ui/input'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; import { ArrowRight, Box, Search } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { ModuleForm, type ModuleView } from './components/ModuleForm'; +import { ModuleForm, type ModuleView } from '../ui/components/ModuleForm'; import { ROUTES } from './routes'; type Props = { diff --git a/modules/settings/settings/reload.py b/modules/settings/settings/reload.py index 9d010c53d..9128f15da 100644 --- a/modules/settings/settings/reload.py +++ b/modules/settings/settings/reload.py @@ -1,162 +1,11 @@ -"""Apply field changes to a module's settings, validate, persist, hot-swap.""" +"""Moved to :mod:`settings.module_settings.reload`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import json -from typing import Any +import importlib +import sys -from fastapi import FastAPI -from pydantic import SecretBytes, SecretStr, ValidationError -from pydantic_core import to_jsonable_python -from pydantic_settings import BaseSettings -from simple_module_core.events import EventBus -from simple_module_core.module_settings import module_settings_registry -from simple_module_core.settings_hydrate import ( - env_supplied_fields, - hydrate_settings, - value_type_for_field, -) +from simple_module_core.deprecation import deprecated_module -from settings.invalidation import reload_on_commit -from settings.store import SettingsStore - - -class EnvPinnedError(ValueError): - """A change targets fields the environment sets, which always win (GH #486). - - Storing it anyway would hot-swap the value into this process only: every - restart and every other worker would hydrate the env value instead. - """ - - def __init__(self, fields: list[str]) -> None: - super().__init__(f"Set by the environment: {', '.join(fields)}") - self.fields = fields - - -def _encode(value: Any, value_type: str) -> str: - if value_type == "json": - return json.dumps(value) - return str(value) - - -def _unwrap_secrets(value: Any) -> Any: - """``value`` with every ``SecretStr``/``SecretBytes`` replaced by its secret. - - The store needs the real credential: a JSON dump masks secrets as - asterisks, also when they sit inside ``X | None`` or a container. - """ - if isinstance(value, SecretStr | SecretBytes): - return value.get_secret_value() - if isinstance(value, dict): - return {k: _unwrap_secrets(v) for k, v in value.items()} - if isinstance(value, list | tuple | set | frozenset): - return [_unwrap_secrets(v) for v in value] - return value - - -def _relocate(exc: ValidationError, field_name: str) -> ValidationError: - """Re-key a stored-value error (``loc=["value"]``) onto the field it was for. - - The write schema validates the encoded ``value`` column, which names no - settings field, so the editor could not show it anywhere. - """ - lines: list[Any] = [ - { - "type": e["type"], - "loc": (field_name, *e["loc"][1:]), - "input": e.get("input"), - "ctx": e.get("ctx", {}), - } - for e in exc.errors() - ] - return ValidationError.from_exception_data(exc.title, lines) - - -def _is_default(cls: type[BaseSettings], field_name: str, value: Any) -> bool: - """Whether the validated ``value`` is what the field defaults to.""" - info = cls.model_fields[field_name] - if info.is_required(): - return False - default = info.get_default(call_default_factory=True) - return to_jsonable_python(_unwrap_secrets(default)) == to_jsonable_python( - _unwrap_secrets(value) - ) - - -async def apply_changes_and_reload( - app: FastAPI, - bus: EventBus, - store: SettingsStore, - *, - package: str, - changes: dict[str, Any], -) -> BaseSettings: - """Validate and persist; reload and publish ``SettingsReloaded`` after commit.""" - registry = module_settings_registry(app) - cls = registry.get(package) - if cls is None: - raise KeyError(f"Unknown settings package: {package!r}") - - unknown = set(changes) - set(cls.model_fields) - if unknown: - raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}") - - # Include prior writes in this transaction without exposing them to other requests. - current = await hydrate_settings(cls, store, package, lenient=True) - # Compare against the *dumped* current value, not the attribute: a field - # typed as a list of models (branding's ``footer_links``) holds model - # instances, while ``changes`` carries the plain dicts a DTO dumps to. The - # attribute comparison never matched those, so an unchanged list was - # rewritten to the store on every save. - merged = current.model_dump() - diff = {k: v for k, v in changes.items() if merged.get(k) != v} - - # A row that already holds the default (stored before saving a default - # cleared the row) is not a change, so the diff never reaches it; without - # this the field stays "overridden in DB" however often it is saved. - unchanged = set(changes) - set(diff) - env_supplied_fields(cls) - if unchanged: - stored = await store.get_overrides(package) - for name in sorted(unchanged & set(stored)): - if _is_default(cls, name, merged.get(name)): - await store.clear_override(package, name) - - if not diff: - reload_on_commit(app, bus, store, package) - return current - - # Only a field that would actually change is refused: a client that echoes - # the whole GET payload back resubmits env-set fields at their live value. - pinned = sorted(set(diff) & env_supplied_fields(cls)) - if pinned: - raise EnvPinnedError(pinned) - - merged.update(diff) - validated = cls(**merged) - dumped_all = validated.model_dump() - - # A json field stores what validated, not what was sent: a field validator - # may have turned the editor's text into the declared type (``json.loads`` - # on a dict), and encoding the raw input stored a JSON *string* that - # hydration could not read back as that type (GH #487). Scalars keep the - # submitted value. Either way a serializer (``SecretStr`` dumps as - # asterisks) must never decide what is written to the store, so secrets - # are unwrapped before the validated value is made JSON-able. - for field_name, raw_value in diff.items(): - vtype = value_type_for_field(cls, field_name) - if _is_default(cls, field_name, dumped_all.get(field_name, raw_value)): - # Saving the default is clearing the override: a stored row would - # keep the field labelled "overridden in DB" while holding exactly - # what the code would have supplied anyway. - await store.clear_override(package, field_name) - continue - if vtype == "json": - dumped = validated.model_dump(include={field_name}).get(field_name, raw_value) - raw_value = to_jsonable_python(_unwrap_secrets(dumped)) - try: - await store.set_override(package, field_name, _encode(raw_value, vtype), vtype) - except ValidationError as exc: - raise _relocate(exc, field_name) from exc - - reload_on_commit(app, bus, store, package) - return validated +deprecated_module("settings.reload", remove_in="0.0.46", use="settings.module_settings.reload") +sys.modules[__name__] = importlib.import_module("settings.module_settings.reload") diff --git a/modules/settings/settings/service.py b/modules/settings/settings/service.py index a683a3efd..44b56412c 100644 --- a/modules/settings/settings/service.py +++ b/modules/settings/settings/service.py @@ -1,237 +1,11 @@ -"""Setting service implementation — scoped key/value CRUD + resolution.""" +"""Moved to :mod:`settings.core.service`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from typing import TYPE_CHECKING +import importlib +import sys -from simple_module_db.stored_settings import SYSTEM_SCOPE_ID, VALUE_TYPE_STRING, Setting -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import col +from simple_module_core.deprecation import deprecated_module -from settings._announce import announce -from settings._listing import SettingListing -from settings._managed_keys import ModuleKeyError, TenantIsLive, ensure_deletable -from settings._row_masking import drop_placeholder_write, is_placeholder_write, out -from settings._unique_write import DuplicateSettingError, insert_if_free -from settings.constants import VIEW_PREFIX -from settings.contracts.schemas import ( - SettingCreate, - SettingOut, - SettingScope, - SettingUpdate, - SettingUpsert, -) - -if TYPE_CHECKING: - from simple_module_core.invalidation import InvalidationBus - from simple_module_core.module_settings import ModuleSettingsRegistry - - from settings.contracts.registry import SettingsRegistry - - -class SettingService(SettingListing): - """Async CRUD + scope resolution for key/value settings. - - Resolution precedence when calling ``resolve`` / ``get_resolved_value``: - USER > TENANT > SYSTEM. The first match in that chain is returned. - """ - - def __init__( - self, - db: AsyncSession, - invalidation: InvalidationBus | None = None, - registry: SettingsRegistry | None = None, - tenant_is_live: TenantIsLive | None = None, - ) -> None: - self.db = db - self.module_registry: ModuleSettingsRegistry | None = None - # With a registry, ``delete``/``delete_scoped`` refuse a ``clear_via`` - # key (see ``_managed_keys``); ``tenant_is_live`` lets a deleted - # tenant's leftover row through. - self.registry = registry - self.tenant_is_live = tenant_is_live - # When given, SYSTEM/TENANT writes announce themselves after commit so - # per-tenant caches elsewhere drop the (tenant, key) they hold. - self.invalidation = invalidation - - def _changed(self, entity: Setting) -> None: - announce(self.db, self.invalidation, entity.scope, entity.scope_id, entity.key) - - def _ensure_generic_key(self, scope: SettingScope | str, key: str) -> None: - """Refuse a system row for a module field: only its editor validates and reloads. - - Tenant (and user) rows stay writable here: a ``tenant_overridable`` - module key's per-tenant override has no other platform route (#690). - """ - if SettingScope(scope) is not SettingScope.SYSTEM: - return - package, sep, field = key.partition(".") - registry = self.module_registry - settings_cls = registry.get(package) if sep and registry is not None else None - # An orphaned row (no such field any more) has no editor to clear it, so - # it stays deletable here. - if settings_cls is not None and field in settings_cls.model_fields: - raise ModuleKeyError(package, registry.manage_url(package) or f"{VIEW_PREFIX}/") - - # ── Lookup ────────────────────────────────────────────────────── - - async def get_by_id(self, setting_id: int) -> SettingOut | None: - entity = await self.db.get(Setting, setting_id) - if entity is None: - return None - return out(entity) - - async def get_scoped(self, scope: SettingScope, scope_id: str, key: str) -> SettingOut | None: - entity = await self._find(scope, scope_id, key) - return out(entity) if entity is not None else None - - async def _resolve_entity( - self, - key: str, - user_id: str | None = None, - tenant_id: str | None = None, - ) -> Setting | None: - """First match walking USER > TENANT > SYSTEM, unserialized.""" - if user_id: - entity = await self._find(SettingScope.USER, user_id, key) - if entity is not None: - return entity - if tenant_id: - entity = await self._find(SettingScope.TENANT, tenant_id, key) - if entity is not None: - return entity - return await self._find(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key) - - async def resolve( - self, - key: str, - user_id: str | None = None, - tenant_id: str | None = None, - ) -> SettingOut | None: - """The resolved row as the API returns it — masked if it is a secret.""" - entity = await self._resolve_entity(key, user_id=user_id, tenant_id=tenant_id) - return out(entity) if entity is not None else None - - async def get_resolved_value( - self, - key: str, - user_id: str | None = None, - tenant_id: str | None = None, - default: str | None = None, - ) -> str | None: - """The resolved *value*, unmasked — this is what consumers act on. - - ``SettingsAccessor`` is the read path other modules use to get a value - they are about to use, not one they are about to render. Masking here - would hand a module the placeholder instead of its own API key. The - masked view of the same row is :meth:`resolve`, which is what the API - returns. - """ - entity = await self._resolve_entity(key, user_id=user_id, tenant_id=tenant_id) - return entity.value if entity is not None else default - - # ── Mutations ─────────────────────────────────────────────────── - - async def create(self, data: SettingCreate) -> SettingOut: - self._ensure_generic_key(data.scope, data.key) - entity = Setting(**data.model_dump()) - if not await insert_if_free(self.db, entity): - raise DuplicateSettingError(f"{data.scope.value}/{data.scope_id}/{data.key}") - await self.db.refresh(entity) - self._changed(entity) - return out(entity) - - async def update(self, setting_id: int, data: SettingUpdate) -> SettingOut | None: - entity = await self.db.get(Setting, setting_id) - if entity is None: - return None - self._ensure_generic_key(entity.scope, entity.key) - changes = data.model_dump(exclude_unset=True) - drop_placeholder_write(entity, changes) - for field, value in changes.items(): - setattr(entity, field, value) - await self.db.flush() - await self.db.refresh(entity) - self._changed(entity) - return out(entity) - - async def upsert_scoped( - self, - scope: SettingScope, - scope_id: str, - key: str, - data: SettingUpsert, - ) -> SettingOut: - self._ensure_generic_key(scope, key) - entity = await self._find(scope, scope_id, key) - if entity is None: - entity = Setting( - scope=scope.value, - scope_id=scope_id, - key=key, - value=data.value, - value_type=( - data.value_type.value if data.value_type is not None else VALUE_TYPE_STRING - ), - description=data.description, - ) - # Two first-time writers can both get here; the loser's insert is - # refused by the unique key, and it becomes an update of the winner's row. - if await insert_if_free(self.db, entity): - await self.db.refresh(entity) - self._changed(entity) - return out(entity) - entity = await self._find(scope, scope_id, key) - if entity is None: # deleted again between the two statements - raise DuplicateSettingError(f"{scope.value}/{scope_id}/{key}") - if not is_placeholder_write(entity, data.value): - entity.value = data.value - if data.value_type is not None: - entity.value_type = data.value_type.value - # Honor explicit description=None as "clear"; skip only when unset. - if "description" in data.model_fields_set: - entity.description = data.description - await self.db.flush() - await self.db.refresh(entity) - self._changed(entity) - return out(entity) - - async def delete(self, setting_id: int, *, as_owner: bool = False) -> bool: - entity = await self.db.get(Setting, setting_id) - if entity is None: - return False - await self._remove(entity, as_owner) - return True - - async def delete_scoped( - self, scope: SettingScope, scope_id: str, key: str, *, as_owner: bool = False - ) -> bool: - """Delete one row; ``as_owner`` is for the module that reaps a ``clear_via`` key's file.""" - entity = await self._find(scope, scope_id, key) - if entity is None: - return False - await self._remove(entity, as_owner) - return True - - async def _remove(self, entity: Setting, as_owner: bool) -> None: - if not as_owner: - # A managed key's own clear route is the more specific refusal. - await ensure_deletable( - self.registry, self.tenant_is_live, entity.scope, entity.scope_id, entity.key - ) - self._ensure_generic_key(entity.scope, entity.key) - self._changed(entity) - await self.db.delete(entity) - await self.db.flush() - - # ── Internals ─────────────────────────────────────────────────── - - async def _find(self, scope: SettingScope, scope_id: str, key: str) -> Setting | None: - stmt = select(Setting).where( - col(Setting.scope) == scope.value, - col(Setting.scope_id) == scope_id, - col(Setting.key) == key, - ) - result = await self.db.execute(stmt) - return result.scalar_one_or_none() +deprecated_module("settings.service", remove_in="0.0.46", use="settings.core.service") +sys.modules[__name__] = importlib.import_module("settings.core.service") diff --git a/modules/settings/settings/store.py b/modules/settings/settings/store.py index 5e1d8d91c..e8e43dcf1 100644 --- a/modules/settings/settings/store.py +++ b/modules/settings/settings/store.py @@ -1,92 +1,11 @@ -"""DB-backed key/value store for module settings (SYSTEM scope). - -The read-only queries (``package_overrides``, ``get_overrides_sync``) moved to -``simple_module_db.stored_settings`` with the table (#500) and are re-exported -here. ``SettingsStore`` satisfies -``simple_module_core.settings_hydrate.OverrideSource``. - -Wraps the existing SettingService. Keys are namespaced ``.`` -to avoid collision with free-form user-defined setting keys. - -Reads are deliberately *unmasked*. This store is what applies overrides to the -live settings objects at boot, not what renders them: the masking on the -service's ordinary read path is for the admin screens, and going through it here -would write a row of dots over every real credential the deployment had stored. -The display side masks independently, from the settings object it is given — -see ``_module_settings._field_view``. -""" +"""Moved to :mod:`settings.module_settings.store`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from simple_module_db.callbacks import OnCommitCallback -from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500) - SYSTEM_SCOPE_ID, - get_overrides_sync, - package_overrides, -) - -from settings.contracts.schemas import SettingScope, SettingUpsert, SettingValueType -from settings.service import SettingService - - -def _key(package: str, field: str) -> str: - return f"{package}.{field}" - - -class SettingsStore: - """SYSTEM-scoped key/value store keyed by ``(package, field)``. - - Satisfies ``OverrideSource`` for :func:`hydrate_settings`. - """ - - def __init__(self, service: SettingService) -> None: - self._service = service - - def on_commit(self, callback: OnCommitCallback) -> None: - self._service.db.on_commit(callback) - - async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]: - """Return ``{field_name: (raw_value, value_type)}`` for a package.""" - items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) - return package_overrides(((i.key, i.value, i.value_type) for i in items), package) - - async def all_override_fields(self) -> dict[str, frozenset[str]]: - """Return ``{package: {field_name, ...}}`` for every stored override. - - One query for the whole screen. ``get_overrides`` re-reads the entire - SYSTEM scope per package, so calling it in a loop over the installed - modules is one full read per module for the same rows. - """ - items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) - out: dict[str, set[str]] = {} - for item in items: - package, sep, field_name = item.key.partition(".") - if not sep or not field_name or "." in field_name: - continue - out.setdefault(package, set()).add(field_name) - return {package: frozenset(fields) for package, fields in out.items()} - - async def set_override(self, package: str, field: str, value: str, value_type: str) -> None: - await self._service.upsert_scoped( - SettingScope.SYSTEM, - SYSTEM_SCOPE_ID, - _key(package, field), - SettingUpsert(value=value, value_type=SettingValueType(value_type)), - ) +import importlib +import sys - async def clear_override(self, package: str, field: str) -> None: - await self._service.delete_scoped( - SettingScope.SYSTEM, SYSTEM_SCOPE_ID, _key(package, field) - ) +from simple_module_core.deprecation import deprecated_module - async def list_packages_with_overrides(self) -> list[str]: - items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) - pkgs: set[str] = set() - for item in items: - if "." not in item.key: - continue - pkg, rest = item.key.split(".", 1) - if "." in rest: - continue - pkgs.add(pkg) - return sorted(pkgs) +deprecated_module("settings.store", remove_in="0.0.46", use="settings.module_settings.store") +sys.modules[__name__] = importlib.import_module("settings.module_settings.store") diff --git a/modules/settings/settings/tenant_scope.py b/modules/settings/settings/tenant_scope.py index 040600a09..72b0cce4b 100644 --- a/modules/settings/settings/tenant_scope.py +++ b/modules/settings/settings/tenant_scope.py @@ -1,130 +1,11 @@ -"""Guards for TENANT-scope writes (#382). - -Two surfaces write tenant-scope rows, and they trust different things: - -* the **platform** routes (``/api/settings/tenant/{scope_id}/…``, guarded by - ``settings.*``) take the tenant from the URL, because a platform operator - legitimately edits any tenant. The id must name a real tenant - (:func:`require_known_tenant`) — a typo used to create a row no tenant would - ever read. -* the **self-service** routes (``/api/settings/tenant/current/…``, guarded by - ``settings.tenant.edit``) take the tenant from ``request.state.tenant_id`` - only (:func:`active_tenant`), and only for keys declared - ``tenant_overridable`` (:func:`overridable_definition`). - -A key with a ``clear_via`` is protected from generic deletes by -``SettingService`` itself (``_managed_keys``), not by anything here. - -Both run the definition's ``check`` (:func:`run_check`) before writing. -""" +"""Moved to :mod:`settings.core.tenant_scope`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from fastapi import HTTPException, Request -from simple_module_core.tenancy import tenant_exists - -from settings.constants import ( - ERR_NO_ACTIVE_TENANT, - ERR_NOT_TENANT_OVERRIDABLE, - ERR_UNKNOWN_TENANT, - MODULE_PACKAGE, - STATUS_FORBIDDEN, - STATUS_NOT_FOUND, - STATUS_UNPROCESSABLE, -) -from settings.contracts.registry import SettingDefinition, SettingsRegistry -from settings.contracts.schemas import SettingOut, SettingScope - - -def registry_of(request: Request) -> SettingsRegistry | None: - services = getattr(request.app.state, MODULE_PACKAGE, None) - return getattr(services, "registry", None) - - -async def is_known_tenant(request: Request, tenant_id: str) -> bool: - """False only when a tenant-owning module says the id is unknown. - - With no such module installed (``tenant_exists`` answers ``None``) every id - is accepted, which is the pre-#382 behaviour single-tenant installs rely on. - """ - return await tenant_exists(request.app, tenant_id) is not False - - -async def require_known_tenant(request: Request, tenant_id: str) -> None: - if not await is_known_tenant(request, tenant_id): - raise HTTPException(status_code=STATUS_NOT_FOUND, detail=ERR_UNKNOWN_TENANT) - - -def active_tenant(request: Request) -> str: - """The request's resolved tenant; 403 when it acts for none.""" - tenant_id = getattr(request.state, "tenant_id", None) - if not tenant_id: - raise HTTPException(status_code=STATUS_FORBIDDEN, detail=ERR_NO_ACTIVE_TENANT) - return tenant_id - - -def overridable_definition(request: Request, key: str) -> SettingDefinition: - """The key's definition when a tenant may override it; 422 otherwise.""" - registry = registry_of(request) - definition = registry.get(key) if registry is not None else None - if definition is None or not definition.tenant_overridable: - raise HTTPException(status_code=STATUS_UNPROCESSABLE, detail=ERR_NOT_TENANT_OVERRIDABLE) - return definition - - -async def run_check(request: Request, tenant_id: str, key: str, value: str) -> None: - """Run the key's declared ``check`` for a write at ``tenant_id``, if any.""" - registry = registry_of(request) - definition = registry.get(key) if registry is not None else None - if definition is None or definition.check is None: - return - try: - await definition.check(request, tenant_id, value) - except LookupError as exc: - raise HTTPException(status_code=STATUS_NOT_FOUND, detail=str(exc) or key) from exc - except ValueError as exc: - raise HTTPException(status_code=STATUS_UNPROCESSABLE, detail=str(exc) or key) from exc - - -async def tenant_write_error(request: Request, tenant_id: str, key: str, value: str) -> str | None: - """The reason a platform form may not write ``key`` at ``tenant_id``, or ``None``. - - For the Inertia store form, which reports errors on the field rather than - as an HTTP status. - """ - if not await is_known_tenant(request, tenant_id): - return ERR_UNKNOWN_TENANT - try: - await run_check(request, tenant_id, key, value) - except HTTPException as exc: - return str(exc.detail) - return None - - -async def tenant_update_error( - request: Request, current: SettingOut | None, value: str | None -) -> str | None: - """:func:`tenant_write_error` for the edit form (``PUT /settings/{id}``). - - The row's own scope, tenant and key decide — an update cannot move a row. - Only a TENANT row whose value actually changes is checked: a - description-only edit, or the masked echo of an unchanged secret, writes - nothing the check could object to. - """ - if current is None or current.scope != SettingScope.TENANT: - return None - if value is None or value == current.value: - return None - return await tenant_write_error(request, current.scope_id, current.key, value) +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = [ - "active_tenant", - "is_known_tenant", - "overridable_definition", - "registry_of", - "require_known_tenant", - "run_check", - "tenant_update_error", - "tenant_write_error", -] +deprecated_module("settings.tenant_scope", remove_in="0.0.46", use="settings.core.tenant_scope") +sys.modules[__name__] = importlib.import_module("settings.core.tenant_scope") diff --git a/modules/settings/settings/tenant_view.py b/modules/settings/settings/tenant_view.py index 847a9d5fc..6bd3ed105 100644 --- a/modules/settings/settings/tenant_view.py +++ b/modules/settings/settings/tenant_view.py @@ -1,85 +1,11 @@ -"""What a tenant sees of its overridable settings (#382). - -One row per ``tenant_overridable`` definition: the value the tenant inherits -(system override, else the declared default), its own override if any, and the -effective value — the same precedence ``SettingsAccessor`` applies at runtime. -Used by the self-service list endpoint and by the tenant settings page. -""" +"""Moved to :mod:`settings.core.tenant_view`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from simple_module_core.secret_fields import conceals_secret, mask -from simple_module_db.stored_settings import SYSTEM_SCOPE_ID -from sqlmodel import SQLModel - -from settings.contracts.registry import SettingsRegistry -from settings.contracts.schemas import SettingScope, SettingValueType -from settings.service import SettingService - - -class TenantSettingView(SQLModel): - """One overridable key, as the active tenant sees it.""" - - key: str - description: str - value_type: SettingValueType - inherited: str - """What applies without a tenant override: the system value, else the default.""" - value: str | None - """The tenant's own override, ``None`` while it inherits.""" - effective: str - upload_url: str = "" - """Set for a file-id key: upload here (POST) / clear here (DELETE).""" - description_key: str = "" - """i18n key for ``description``; the client falls back to ``description``.""" - label_key: str = "" - """i18n key for the row title; the client falls back to ``key``.""" - - -def _shown(key: str, value: str | None, value_type: str) -> str | None: - if value is None: - return None - return mask(value) if conceals_secret(key, value, value_type) else value - - -async def list_for_tenant( - service: SettingService, registry: SettingsRegistry | None, tenant_id: str -) -> list[TenantSettingView]: - if registry is None: - return [] - definitions = registry.tenant_overridable - if not definitions: - return [] - keys = {d.key for d in definitions} - system = { - row.key: row.value - for row in await service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID) - if row.key in keys - } - own = { - row.key: row.value - for row in await service.list_by_scope_unmasked(SettingScope.TENANT, tenant_id) - if row.key in keys - } - views = [] - for d in definitions: - inherited = system.get(d.key, d.default) - value = own.get(d.key) - views.append( - TenantSettingView( - key=d.key, - description=d.description, - value_type=d.value_type, - inherited=_shown(d.key, inherited, d.value_type) or "", - value=_shown(d.key, value, d.value_type), - effective=_shown(d.key, value if value is not None else inherited, d.value_type) - or "", - upload_url=d.upload_url, - description_key=d.description_key, - label_key=d.label_key, - ) - ) - return views +import importlib +import sys +from simple_module_core.deprecation import deprecated_module -__all__ = ["TenantSettingView", "list_for_tenant"] +deprecated_module("settings.tenant_view", remove_in="0.0.46", use="settings.core.tenant_view") +sys.modules[__name__] = importlib.import_module("settings.core.tenant_view") diff --git a/modules/settings/settings/pages/components/FieldInput.tsx b/modules/settings/settings/ui/components/FieldInput.tsx similarity index 100% rename from modules/settings/settings/pages/components/FieldInput.tsx rename to modules/settings/settings/ui/components/FieldInput.tsx diff --git a/modules/settings/settings/pages/components/FieldSource.tsx b/modules/settings/settings/ui/components/FieldSource.tsx similarity index 100% rename from modules/settings/settings/pages/components/FieldSource.tsx rename to modules/settings/settings/ui/components/FieldSource.tsx diff --git a/modules/settings/settings/pages/components/FormLevelError.tsx b/modules/settings/settings/ui/components/FormLevelError.tsx similarity index 100% rename from modules/settings/settings/pages/components/FormLevelError.tsx rename to modules/settings/settings/ui/components/FormLevelError.tsx diff --git a/modules/settings/settings/pages/components/JsonInput.tsx b/modules/settings/settings/ui/components/JsonInput.tsx similarity index 100% rename from modules/settings/settings/pages/components/JsonInput.tsx rename to modules/settings/settings/ui/components/JsonInput.tsx diff --git a/modules/settings/settings/pages/components/KeyField.tsx b/modules/settings/settings/ui/components/KeyField.tsx similarity index 99% rename from modules/settings/settings/pages/components/KeyField.tsx rename to modules/settings/settings/ui/components/KeyField.tsx index 15e63e481..edc066c1f 100644 --- a/modules/settings/settings/pages/components/KeyField.tsx +++ b/modules/settings/settings/ui/components/KeyField.tsx @@ -2,7 +2,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { Label } from '@simple-module-py/ui/components/ui/label'; import { useEffect, useMemo, useRef, useState } from 'react'; -import type { ValueType } from '../types'; +import type { ValueType } from '../../pages/types'; export interface KnownKey { key: string; diff --git a/modules/settings/settings/pages/components/ModuleFieldRow.tsx b/modules/settings/settings/ui/components/ModuleFieldRow.tsx similarity index 100% rename from modules/settings/settings/pages/components/ModuleFieldRow.tsx rename to modules/settings/settings/ui/components/ModuleFieldRow.tsx diff --git a/modules/settings/settings/pages/components/ModuleForm.tsx b/modules/settings/settings/ui/components/ModuleForm.tsx similarity index 99% rename from modules/settings/settings/pages/components/ModuleForm.tsx rename to modules/settings/settings/ui/components/ModuleForm.tsx index d18231001..42127fe8b 100644 --- a/modules/settings/settings/pages/components/ModuleForm.tsx +++ b/modules/settings/settings/ui/components/ModuleForm.tsx @@ -4,7 +4,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card } from '@simple-module-py/ui/components/ui/card'; import { apiResult } from '@simple-module-py/ui/lib/api'; import { useEffect, useMemo, useState } from 'react'; -import { api } from '../api'; +import { api } from '../../pages/api'; import type { FieldMeta } from './FieldInput'; import { ModuleFieldRow } from './ModuleFieldRow'; import { TestConnectionButton } from './TestConnectionButton'; diff --git a/modules/settings/settings/pages/components/ResolvedValue.tsx b/modules/settings/settings/ui/components/ResolvedValue.tsx similarity index 100% rename from modules/settings/settings/pages/components/ResolvedValue.tsx rename to modules/settings/settings/ui/components/ResolvedValue.tsx diff --git a/modules/settings/settings/pages/components/ScopeTabs.tsx b/modules/settings/settings/ui/components/ScopeTabs.tsx similarity index 100% rename from modules/settings/settings/pages/components/ScopeTabs.tsx rename to modules/settings/settings/ui/components/ScopeTabs.tsx diff --git a/modules/settings/settings/pages/components/StoreCards.tsx b/modules/settings/settings/ui/components/StoreCards.tsx similarity index 96% rename from modules/settings/settings/pages/components/StoreCards.tsx rename to modules/settings/settings/ui/components/StoreCards.tsx index 5a1b08528..c48a19b6a 100644 --- a/modules/settings/settings/pages/components/StoreCards.tsx +++ b/modules/settings/settings/ui/components/StoreCards.tsx @@ -1,7 +1,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Badge } from '@simple-module-py/ui/components/ui/badge'; -import { ROUTES } from '../routes'; -import type { Setting, SettingScope } from '../types'; +import { ROUTES } from '../../pages/routes'; +import type { Setting, SettingScope } from '../../pages/types'; const SCOPE_TONE: Record = { system: 'border-primary-200 bg-primary-50 text-primary-700', diff --git a/modules/settings/settings/pages/components/StoreTable.tsx b/modules/settings/settings/ui/components/StoreTable.tsx similarity index 97% rename from modules/settings/settings/pages/components/StoreTable.tsx rename to modules/settings/settings/ui/components/StoreTable.tsx index 3f1a820e8..f977d8c6f 100644 --- a/modules/settings/settings/pages/components/StoreTable.tsx +++ b/modules/settings/settings/ui/components/StoreTable.tsx @@ -8,8 +8,8 @@ import { TableHeader, TableRow, } from '@simple-module-py/ui/components/ui/table'; -import { ROUTES } from '../routes'; -import type { Setting, SettingScope } from '../types'; +import { ROUTES } from '../../pages/routes'; +import type { Setting, SettingScope } from '../../pages/types'; const SCOPE_TONE: Record = { system: 'border-primary-200 bg-primary-50 text-primary-700', diff --git a/modules/settings/settings/pages/components/TestConnectionButton.tsx b/modules/settings/settings/ui/components/TestConnectionButton.tsx similarity index 99% rename from modules/settings/settings/pages/components/TestConnectionButton.tsx rename to modules/settings/settings/ui/components/TestConnectionButton.tsx index 5b43c0a88..8d3278d90 100644 --- a/modules/settings/settings/pages/components/TestConnectionButton.tsx +++ b/modules/settings/settings/ui/components/TestConnectionButton.tsx @@ -4,7 +4,7 @@ import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time'; import { apiFetch } from '@simple-module-py/ui/lib/api'; import { CheckCircle2, PlugZap, XCircle } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import { ROUTES } from '../routes'; +import { ROUTES } from '../../pages/routes'; import { type LastTest, readLastTest, writeLastTest } from './last-test'; interface CheckResult { diff --git a/modules/settings/settings/pages/components/ValueInput.tsx b/modules/settings/settings/ui/components/ValueInput.tsx similarity index 94% rename from modules/settings/settings/pages/components/ValueInput.tsx rename to modules/settings/settings/ui/components/ValueInput.tsx index 640027017..c15502ad8 100644 --- a/modules/settings/settings/pages/components/ValueInput.tsx +++ b/modules/settings/settings/ui/components/ValueInput.tsx @@ -8,10 +8,10 @@ import { SelectValue, } from '@simple-module-py/ui/components/ui/select'; import { Textarea } from '@simple-module-py/ui/components/ui/textarea'; -import type { ValueType } from '../types'; +import type { ValueType } from '../../pages/types'; -export type { ValueType } from '../types'; -export { VALUE_TYPES } from '../types'; +export type { ValueType } from '../../pages/types'; +export { VALUE_TYPES } from '../../pages/types'; type Props = { valueType: ValueType; diff --git a/modules/settings/settings/pages/components/last-test.ts b/modules/settings/settings/ui/components/last-test.ts similarity index 100% rename from modules/settings/settings/pages/components/last-test.ts rename to modules/settings/settings/ui/components/last-test.ts diff --git a/modules/settings/settings/pages/components/validation-errors.test.ts b/modules/settings/settings/ui/components/validation-errors.test.ts similarity index 100% rename from modules/settings/settings/pages/components/validation-errors.test.ts rename to modules/settings/settings/ui/components/validation-errors.test.ts diff --git a/modules/settings/settings/pages/components/validation-errors.ts b/modules/settings/settings/ui/components/validation-errors.ts similarity index 100% rename from modules/settings/settings/pages/components/validation-errors.ts rename to modules/settings/settings/ui/components/validation-errors.ts diff --git a/modules/settings/settings/web/__init__.py b/modules/settings/settings/web/__init__.py new file mode 100644 index 000000000..d8feeb262 --- /dev/null +++ b/modules/settings/settings/web/__init__.py @@ -0,0 +1,5 @@ +"""The HTTP side. + +Request deps, the store screen's query parsing, key autocomplete and the REST, +module, tenant and Inertia endpoints. +""" diff --git a/modules/settings/settings/browse_query.py b/modules/settings/settings/web/browse_query.py similarity index 96% rename from modules/settings/settings/browse_query.py rename to modules/settings/settings/web/browse_query.py index adf5885c3..889f63deb 100644 --- a/modules/settings/settings/browse_query.py +++ b/modules/settings/settings/web/browse_query.py @@ -15,8 +15,8 @@ from pydantic import BeforeValidator -from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL from settings.contracts.schemas import SettingScope +from settings.core.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL @dataclass(frozen=True, slots=True) diff --git a/modules/settings/settings/web/deps.py b/modules/settings/settings/web/deps.py new file mode 100644 index 000000000..c4213b33a --- /dev/null +++ b/modules/settings/settings/web/deps.py @@ -0,0 +1,76 @@ +"""FastAPI dependencies for the Settings module. + +Consumers in other modules should almost always depend on ``SettingsDep`` +(the accessor) rather than ``SettingService`` directly — the accessor is +bound to the current request's user/tenant so ``get_bool(key)`` etc. just +work. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_core.app_state import get_services +from simple_module_core.module_settings import get_module_settings_registry +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from settings.contracts.accessor import SettingsAccessor +from settings.contracts.registry import SettingsRegistry +from settings.core.constants import MODULE_PACKAGE +from settings.core.service import SettingService +from settings.core.tenant_scope import is_known_tenant, registry_of + + +async def get_setting_service( + request: Request, + db: AsyncSession = Depends(get_db), +) -> SettingService: + bus = getattr(get_services(request.app), "invalidation", None) + + async def tenant_is_live(tenant_id: str) -> bool: + return await is_known_tenant(request, tenant_id) + + return SettingService( + db, invalidation=bus, registry=registry_of(request), tenant_is_live=tenant_is_live + ) + + +async def get_generic_setting_service( + request: Request, + service: SettingService = Depends(get_setting_service), +) -> SettingService: + """Raw store routes must send module configuration through its own editor.""" + service.module_registry = get_module_settings_registry(request.app) + return service + + +def get_settings_registry(request: Request) -> SettingsRegistry: + """Return the app-wide settings registry populated during boot.""" + services = getattr(request.app.state, MODULE_PACKAGE) + return services.registry + + +async def get_settings_accessor( + request: Request, + service: SettingService = Depends(get_setting_service), + registry: SettingsRegistry = Depends(get_settings_registry), +) -> SettingsAccessor: + """Build a request-scoped accessor bound to the caller's user/tenant. + + ``request.state.user`` is populated by ``users.middleware`` (carries a + ``UserContext``). ``request.state.tenant_id`` is populated by + ``TenantMiddleware`` when ``multi_tenant=True``. Both are optional — + the accessor gracefully degrades to lower scopes (or the registered + default) if the request is unauthenticated or untenanted. + """ + user = getattr(request.state, "user", None) + user_id = str(user.id) if user is not None else None + tenant_id = getattr(request.state, "tenant_id", None) + return SettingsAccessor(service, registry, user_id=user_id, tenant_id=tenant_id) + + +# Convenience aliases for typing annotations in consumer modules. +SettingServiceDep = Annotated[SettingService, Depends(get_setting_service)] +SettingsDep = Annotated[SettingsAccessor, Depends(get_settings_accessor)] diff --git a/modules/settings/settings/web/endpoints/__init__.py b/modules/settings/settings/web/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/settings/settings/endpoints/_create_form.py b/modules/settings/settings/web/endpoints/_create_form.py similarity index 82% rename from modules/settings/settings/endpoints/_create_form.py rename to modules/settings/settings/web/endpoints/_create_form.py index 2ab7e6376..43ca32dad 100644 --- a/modules/settings/settings/endpoints/_create_form.py +++ b/modules/settings/settings/web/endpoints/_create_form.py @@ -7,12 +7,12 @@ from simple_module_hosting.inertia_utils import redirect_back_with_errors, validation_errors_to_dict from starlette.responses import RedirectResponse -from settings._managed_keys import ModuleKeyError -from settings._unique_write import DuplicateSettingError -from settings.constants import ERR_SETTING_EXISTS from settings.contracts.schemas import SettingCreate, SettingScope -from settings.service import SettingService -from settings.tenant_scope import tenant_write_error +from settings.core._managed_keys import ModuleKeyError +from settings.core._unique_write import DuplicateSettingError +from settings.core.constants import ERR_SETTING_EXISTS +from settings.core.service import SettingService +from settings.core.tenant_scope import tenant_write_error async def create_from_form( diff --git a/modules/settings/settings/endpoints/api.py b/modules/settings/settings/web/endpoints/api.py similarity index 97% rename from modules/settings/settings/endpoints/api.py rename to modules/settings/settings/web/endpoints/api.py index 18e87cecc..cc56e0600 100644 --- a/modules/settings/settings/endpoints/api.py +++ b/modules/settings/settings/web/endpoints/api.py @@ -6,8 +6,15 @@ from simple_module_db.stored_settings import SYSTEM_SCOPE_ID from simple_module_hosting.permissions import RequiresPermission -from settings._unique_write import DuplicateSettingError -from settings.constants import ( +from settings.contracts.schemas import ( + SettingCreate, + SettingOut, + SettingScope, + SettingUpdate, + SettingUpsert, +) +from settings.core._unique_write import DuplicateSettingError +from settings.core.constants import ( API_BY_ID_PATH, API_RESOLVE_PATH, API_SYSTEM_PATH, @@ -30,20 +37,13 @@ STATUS_NOT_FOUND, STATUS_UNPROCESSABLE, ) -from settings.contracts.schemas import ( - SettingCreate, - SettingOut, - SettingScope, - SettingUpdate, - SettingUpsert, -) -from settings.deps import get_generic_setting_service -from settings.service import SettingService -from settings.tenant_scope import ( +from settings.core.service import SettingService +from settings.core.tenant_scope import ( is_known_tenant, require_known_tenant, run_check, ) +from settings.web.deps import get_generic_setting_service router = APIRouter() diff --git a/modules/settings/settings/web/endpoints/module_api.py b/modules/settings/settings/web/endpoints/module_api.py new file mode 100644 index 000000000..9f07f4c4a --- /dev/null +++ b/modules/settings/settings/web/endpoints/module_api.py @@ -0,0 +1,166 @@ +"""REST endpoints for the per-module settings admin UI. + +PUT drops fields whose value is the mask sentinel so the UI can echo back +masked secrets without clobbering the real value. +""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status +from pydantic import ValidationError +from simple_module_core.module_settings import module_settings_registry +from simple_module_hosting.i18n_deps import TranslatorDep +from simple_module_hosting.permissions import RequiresPermission + +from settings.core.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW +from settings.core.invalidation import reload_on_commit +from settings.core.service import SettingService +from settings.module_settings.discovery import ( + SECRET_MASK, + collect_module_settings, + overrides_by_package, +) +from settings.module_settings.props import serialize +from settings.module_settings.reload import EnvPinnedError, apply_changes_and_reload +from settings.module_settings.store import SettingsStore +from settings.web.deps import get_setting_service + +router = APIRouter(prefix="/modules", tags=["Settings Modules"]) + +# Per-module settings UI exposes raw secret values (mailer password, JWT +# signing keys, etc.) — every endpoint here is gated on the same permissions +# the scoped API uses so a non-admin can't read or mutate module config. +_VIEW = [Depends(RequiresPermission(PERM_VIEW))] +_EDIT = [Depends(RequiresPermission(PERM_EDIT))] +_DELETE = [Depends(RequiresPermission(PERM_DELETE))] + + +def _masked_fields(app: FastAPI, package: str) -> frozenset[str]: + """The fields this package's editor rendered as dots. + + ``ModuleSettingField.is_secret`` is the same flag that drove the input type + and the reveal affordance, so it answers exactly the question here: which + submitted values could be an echo of the mask rather than a real edit. + """ + return frozenset( + field.name + for view in collect_module_settings(app) + if view.package == package + for field in view.fields + if field.is_secret + ) + + +def _strip_mask_sentinels(masked: frozenset[str], changes: dict[str, Any]) -> dict[str, Any]: + """Drop the mask sentinel on the fields that were rendered masked. + + Not on ``is_secret_field(name)``: a value can be masked because it *is* a + credential (a DSN with a password in it) on a field whose name says nothing + of the sort, and storing the row of dots the editor rendered would overwrite + the real connection string. + + Not on the sentinel alone either, which is where this started — that silently + dropped the write on any field whose real value happened to equal + :data:`SECRET_MASK`, with no error and no saved row to show for it. + """ + return { + name: value + for name, value in changes.items() + if not (name in masked and isinstance(value, str) and value == SECRET_MASK) + } + + +@router.get("", dependencies=_VIEW) +async def list_modules( + request: Request, + service: SettingService = Depends(get_setting_service), +) -> dict[str, Any]: + # Match the Inertia ModulesEdit view: without the overrides map, every + # field's `source`/`db_override` would report env/default even when a + # stored override is actually in force. + overrides = await overrides_by_package(service) + views = collect_module_settings(request.app, overrides) + return {"modules": serialize(views)} + + +@router.put("/{package}", dependencies=_EDIT) +async def update_module( + package: str, + changes: dict[str, Any], + request: Request, + t: TranslatorDep, + service: SettingService = Depends(get_setting_service), +) -> dict[str, Any]: + registry = module_settings_registry(request.app) + if registry.get(package) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package") + if registry.manage_url(package) is not None: + # Modules with a purpose-built settings screen (e.g. Branding) own + # their own validation/preview flow — the generic editor only ever + # links out to it (ModulesEdit.tsx), it must not also accept writes + # that bypass that flow. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This module has a dedicated settings page; edit it there instead.", + ) + + cleaned = _strip_mask_sentinels(_masked_fields(request.app, package), changes) + if not cleaned: + return {"ok": True, "changed": []} + + store = SettingsStore(service) + bus = request.app.state.sm.event_bus + try: + await apply_changes_and_reload(request.app, bus, store, package=package, changes=cleaned) + except ValidationError as exc: + # ``exc.errors()`` still leaves ``ctx.error`` holding the raw Python + # exception (not JSON-serializable). Round-trip through pydantic's + # own JSON encoder to get clean, serializable errors. ``input`` is + # dropped: it echoes the submitted value, which may be a secret. + clean = json.loads(exc.json(include_url=False, include_input=False)) + raise HTTPException(status_code=422, detail=clean) from exc + except EnvPinnedError as exc: + # Shaped like a pydantic 422 so the editor shows it on the field. + detail = [ + { + "loc": ["body", name], + "msg": t.t("settings.modules_form.env_pinned"), + "type": "env_pinned", + } + for name in exc.fields + ] + raise HTTPException(status_code=422, detail=detail) from exc + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return {"ok": True, "changed": sorted(cleaned)} + + +@router.delete("/{package}/{field}", status_code=status.HTTP_204_NO_CONTENT, dependencies=_DELETE) +async def clear_module_field( + package: str, + field: str, + request: Request, + service: SettingService = Depends(get_setting_service), +) -> Response: + registry = module_settings_registry(request.app) + cls = registry.get(package) + if cls is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown module package") + if registry.manage_url(package) is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This module has a dedicated settings page; edit it there instead.", + ) + if field not in cls.model_fields: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown field") + + store = SettingsStore(service) + await store.clear_override(package, field) + + reload_on_commit(request.app, request.app.state.sm.event_bus, store, package) + + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/modules/settings/settings/endpoints/tenant_api.py b/modules/settings/settings/web/endpoints/tenant_api.py similarity index 92% rename from modules/settings/settings/endpoints/tenant_api.py rename to modules/settings/settings/web/endpoints/tenant_api.py index 5e2d6aefc..68b536c4b 100644 --- a/modules/settings/settings/endpoints/tenant_api.py +++ b/modules/settings/settings/web/endpoints/tenant_api.py @@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from simple_module_hosting.permissions import RequiresPermission -from settings.constants import ( +from settings.contracts.schemas import SettingOut, SettingScope, SettingUpsert +from settings.core.constants import ( API_TENANT_CURRENT_KEY_PATH, API_TENANT_CURRENT_PATH, ERR_SETTING_NOT_FOUND, @@ -24,16 +25,15 @@ STATUS_NOT_FOUND, STATUS_UNPROCESSABLE, ) -from settings.contracts.schemas import SettingOut, SettingScope, SettingUpsert -from settings.deps import get_setting_service -from settings.service import SettingService -from settings.tenant_scope import ( +from settings.core.service import SettingService +from settings.core.tenant_scope import ( active_tenant, overridable_definition, registry_of, run_check, ) -from settings.tenant_view import TenantSettingView, list_for_tenant +from settings.core.tenant_view import TenantSettingView, list_for_tenant +from settings.web.deps import get_setting_service router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_TENANT_EDIT))]) diff --git a/modules/settings/settings/endpoints/views.py b/modules/settings/settings/web/endpoints/views.py similarity index 95% rename from modules/settings/settings/endpoints/views.py rename to modules/settings/settings/web/endpoints/views.py index 8a1967848..0a0399a17 100644 --- a/modules/settings/settings/endpoints/views.py +++ b/modules/settings/settings/web/endpoints/views.py @@ -21,15 +21,9 @@ from simple_module_inertia import InertiaResponse from starlette.responses import RedirectResponse -from settings import browse_query, known_keys -from settings._managed_keys import ManagedKeyError, ModuleKeyError -from settings._module_settings import ( - _package_of, - collect_module_settings, - overrides_by_package, -) -from settings._module_settings_props import serialize, testable_packages -from settings.constants import ( +from settings.contracts.schemas import SettingUpdate +from settings.core._managed_keys import ManagedKeyError, ModuleKeyError +from settings.core.constants import ( DEFAULT_PER_PAGE, ERR_SETTING_NOT_FOUND, PERM_CREATE, @@ -52,11 +46,17 @@ VIEW_PREFIX, VIEW_STORE_PATH, ) -from settings.contracts.schemas import SettingUpdate -from settings.deps import get_generic_setting_service -from settings.endpoints._create_form import create_from_form -from settings.service import SettingService -from settings.tenant_scope import tenant_update_error +from settings.core.service import SettingService +from settings.core.tenant_scope import tenant_update_error +from settings.module_settings.discovery import ( + _package_of, + collect_module_settings, + overrides_by_package, +) +from settings.module_settings.props import serialize, testable_packages +from settings.web import browse_query, known_keys +from settings.web.deps import get_generic_setting_service +from settings.web.endpoints._create_form import create_from_form _PAGE_BROWSE = "Settings/Browse" _PAGE_CREATE = "Settings/Create" diff --git a/modules/settings/settings/known_keys.py b/modules/settings/settings/web/known_keys.py similarity index 100% rename from modules/settings/settings/known_keys.py rename to modules/settings/settings/web/known_keys.py diff --git a/modules/settings/tests-js/JsonFields.test.tsx b/modules/settings/tests-js/JsonFields.test.tsx index 86ea4e6a2..fa8f48ca8 100644 --- a/modules/settings/tests-js/JsonFields.test.tsx +++ b/modules/settings/tests-js/JsonFields.test.tsx @@ -18,8 +18,8 @@ configureI18n({ vi.mock('@inertiajs/react', () => ({ router: { reload: vi.fn() } })); -import type { FieldMeta } from '../settings/pages/components/FieldInput'; -import { ModuleForm } from '../settings/pages/components/ModuleForm'; +import type { FieldMeta } from '../settings/ui/components/FieldInput'; +import { ModuleForm } from '../settings/ui/components/ModuleForm'; function field(overrides: Partial & { name: string }): FieldMeta { return { @@ -115,7 +115,7 @@ describe('JSON-typed fields (GH #487)', () => { describe('JsonInput', () => { test('replaces its text when the value changes from outside', async () => { - const { JsonInput } = await import('../settings/pages/components/JsonInput'); + const { JsonInput } = await import('../settings/ui/components/JsonInput'); const props = { name: 'routes', nullable: false, className: '', onChange: vi.fn() }; const { rerender } = render(); fireEvent.change(screen.getByRole('textbox'), { target: { value: '{"x": "y"}' } }); diff --git a/modules/settings/tests-js/ModuleForm.test.tsx b/modules/settings/tests-js/ModuleForm.test.tsx index a47108ce5..5901a6446 100644 --- a/modules/settings/tests-js/ModuleForm.test.tsx +++ b/modules/settings/tests-js/ModuleForm.test.tsx @@ -28,8 +28,8 @@ vi.mock('@inertiajs/react', () => ({ router: { reload: mocks.reload }, })); -import type { FieldMeta } from '../settings/pages/components/FieldInput'; -import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm'; +import type { FieldMeta } from '../settings/ui/components/FieldInput'; +import { ModuleForm, type ModuleView } from '../settings/ui/components/ModuleForm'; function field(overrides: Partial & { name: string }): FieldMeta { return { diff --git a/modules/settings/tests-js/RestartHint.test.tsx b/modules/settings/tests-js/RestartHint.test.tsx index 57e0cec58..d56dee1b6 100644 --- a/modules/settings/tests-js/RestartHint.test.tsx +++ b/modules/settings/tests-js/RestartHint.test.tsx @@ -14,8 +14,8 @@ configureI18n({ vi.mock('@inertiajs/react', () => ({ router: { reload: vi.fn() } })); -import type { FieldMeta } from '../settings/pages/components/FieldInput'; -import { ModuleForm } from '../settings/pages/components/ModuleForm'; +import type { FieldMeta } from '../settings/ui/components/FieldInput'; +import { ModuleForm } from '../settings/ui/components/ModuleForm'; // Build-time host settings (#499): an edit only applies after a restart, and // the editor has to say so once the operator changes the value. diff --git a/modules/settings/tests-js/ScopeTabs.test.tsx b/modules/settings/tests-js/ScopeTabs.test.tsx index a80e234d2..c94e972b5 100644 --- a/modules/settings/tests-js/ScopeTabs.test.tsx +++ b/modules/settings/tests-js/ScopeTabs.test.tsx @@ -14,7 +14,7 @@ configureI18n({ }, }); -import { type ScopeCounts, ScopeTabs } from '../settings/pages/components/ScopeTabs'; +import { type ScopeCounts, ScopeTabs } from '../settings/ui/components/ScopeTabs'; const counts: ScopeCounts = { all: 42, system: 28, tenant: 9, user: 5 }; diff --git a/modules/settings/tests-js/settings_FormLevelError.test.tsx b/modules/settings/tests-js/settings_FormLevelError.test.tsx index 60abd3780..d36c71ffe 100644 --- a/modules/settings/tests-js/settings_FormLevelError.test.tsx +++ b/modules/settings/tests-js/settings_FormLevelError.test.tsx @@ -1,7 +1,7 @@ import '@testing-library/jest-dom/vitest'; import { render, screen } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; -import { FormLevelError } from '../settings/pages/components/FormLevelError'; +import { FormLevelError } from '../settings/ui/components/FormLevelError'; describe('FormLevelError', () => { test('renders a model-level error flashed under errors.general', () => { diff --git a/modules/settings/tests-js/settings_ModuleFieldLayout.test.tsx b/modules/settings/tests-js/settings_ModuleFieldLayout.test.tsx index 919da11f3..e4f35b2f7 100644 --- a/modules/settings/tests-js/settings_ModuleFieldLayout.test.tsx +++ b/modules/settings/tests-js/settings_ModuleFieldLayout.test.tsx @@ -1,9 +1,9 @@ import { configureI18n } from '@simple-module-py/i18n'; import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; -import type { FieldMeta } from '../settings/pages/components/FieldInput'; -import { ModuleFieldRow } from '../settings/pages/components/ModuleFieldRow'; -import { ModuleForm } from '../settings/pages/components/ModuleForm'; +import type { FieldMeta } from '../settings/ui/components/FieldInput'; +import { ModuleFieldRow } from '../settings/ui/components/ModuleFieldRow'; +import { ModuleForm } from '../settings/ui/components/ModuleForm'; configureI18n({ locale: 'en', messages: {} }); diff --git a/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx b/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx index 4da66d352..5353b6f74 100644 --- a/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx +++ b/modules/settings/tests-js/settings_ModuleFormRevert.test.tsx @@ -2,9 +2,9 @@ import '@testing-library/jest-dom/vitest'; import { configureI18n } from '@simple-module-py/i18n'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; -import type { FieldMeta } from '../settings/pages/components/FieldInput'; -import { ModuleForm, type ModuleView } from '../settings/pages/components/ModuleForm'; import ModulesEdit from '../settings/pages/ModulesEdit'; +import type { FieldMeta } from '../settings/ui/components/FieldInput'; +import { ModuleForm, type ModuleView } from '../settings/ui/components/ModuleForm'; const mocks = vi.hoisted(() => ({ reload: vi.fn() })); vi.mock('@inertiajs/react', () => ({ router: { reload: mocks.reload }, Head: () => null })); diff --git a/modules/settings/tests-js/settings_TestConnectionButton.test.tsx b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx index 848f317c9..d3dd02b9c 100644 --- a/modules/settings/tests-js/settings_TestConnectionButton.test.tsx +++ b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; configureI18n({ locale: 'en', messages: {} }); -import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton'; +import { TestConnectionButton } from '../settings/ui/components/TestConnectionButton'; function stub(status: number, body: unknown) { const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); diff --git a/modules/settings/tests/test_settings_accessor.py b/modules/settings/tests/core/contracts/test_settings_accessor.py similarity index 99% rename from modules/settings/tests/test_settings_accessor.py rename to modules/settings/tests/core/contracts/test_settings_accessor.py index 3cc132004..ff9cb93a9 100644 --- a/modules/settings/tests/test_settings_accessor.py +++ b/modules/settings/tests/core/contracts/test_settings_accessor.py @@ -6,7 +6,7 @@ from settings.contracts.accessor import SettingsAccessor from settings.contracts.registry import SettingDefinition, SettingsRegistry from settings.contracts.schemas import SettingScope, SettingUpsert, SettingValueType -from settings.service import SettingService +from settings.core.service import SettingService from sqlalchemy.ext.asyncio import AsyncSession diff --git a/modules/settings/tests/test_settings_events.py b/modules/settings/tests/core/contracts/test_settings_events.py similarity index 100% rename from modules/settings/tests/test_settings_events.py rename to modules/settings/tests/core/contracts/test_settings_events.py diff --git a/modules/settings/tests/test_settings_framework_reexports.py b/modules/settings/tests/core/contracts/test_settings_framework_reexports.py similarity index 94% rename from modules/settings/tests/test_settings_framework_reexports.py rename to modules/settings/tests/core/contracts/test_settings_framework_reexports.py index f395edb1a..22b332446 100644 --- a/modules/settings/tests/test_settings_framework_reexports.py +++ b/modules/settings/tests/core/contracts/test_settings_framework_reexports.py @@ -20,7 +20,8 @@ def test_setting_model_is_one_class() -> None: def test_moved_constants_and_helpers_are_the_same_objects() -> None: - from settings import _secrets, constants, store + from settings.core import _secrets, constants + from settings.module_settings import store assert constants.SYSTEM_SCOPE_ID == db_settings.SYSTEM_SCOPE_ID assert constants.TABLE_SETTING == db_settings.TABLE_SETTING diff --git a/modules/settings/tests/test_settings_schemas.py b/modules/settings/tests/core/contracts/test_settings_schemas.py similarity index 100% rename from modules/settings/tests/test_settings_schemas.py rename to modules/settings/tests/core/contracts/test_settings_schemas.py diff --git a/modules/settings/tests/test_cli_import.py b/modules/settings/tests/core/module/test_settings_cli_import.py similarity index 89% rename from modules/settings/tests/test_cli_import.py rename to modules/settings/tests/core/module/test_settings_cli_import.py index e03fa3721..3dd373fc9 100644 --- a/modules/settings/tests/test_cli_import.py +++ b/modules/settings/tests/core/module/test_settings_cli_import.py @@ -38,8 +38,8 @@ async def test_import_from_env_writes_overrides(db_session, monkeypatch, app) -> monkeypatch.setenv("SM_BG_TASKS_RETENTION_DAYS", "30") from settings.cli import import_from_env_impl - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) n = await import_from_env_impl(app, store) @@ -61,8 +61,8 @@ async def test_import_ignores_unknown_env(db_session, monkeypatch, app) -> None: monkeypatch.setenv("SM_USERS_DOES_NOT_EXIST", "value") from settings.cli import import_from_env_impl - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) n = await import_from_env_impl(app, store) @@ -81,8 +81,8 @@ async def test_import_reads_dotenv_by_default(db_session, monkeypatch, app, tmp_ monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "false") from settings.cli import import_from_env_impl - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) n = await import_from_env_impl(app, store) @@ -99,8 +99,8 @@ async def test_import_accepts_explicit_environ(db_session, monkeypatch, app) -> monkeypatch.delenv("SM_MAIL_SMTP_PORT", raising=False) from settings.cli import import_from_env_impl - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) n = await import_from_env_impl(app, store, environ={"SM_MAIL_SMTP_PORT": "2727"}) diff --git a/modules/settings/tests/test_settings_host_restart_flags.py b/modules/settings/tests/core/module/test_settings_host_restart_flags.py similarity index 92% rename from modules/settings/tests/test_settings_host_restart_flags.py rename to modules/settings/tests/core/module/test_settings_host_restart_flags.py index 272f511b4..25d663a7d 100644 --- a/modules/settings/tests/test_settings_host_restart_flags.py +++ b/modules/settings/tests/core/module/test_settings_host_restart_flags.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from settings._module_settings import _build_view +from settings.module_settings.discovery import _build_view from simple_module_hosting.host_settings import HostSettings diff --git a/modules/settings/tests/test_settings_module.py b/modules/settings/tests/core/module/test_settings_module.py similarity index 86% rename from modules/settings/tests/test_settings_module.py rename to modules/settings/tests/core/module/test_settings_module.py index 2a69d4bfb..2c78621c5 100644 --- a/modules/settings/tests/test_settings_module.py +++ b/modules/settings/tests/core/module/test_settings_module.py @@ -2,7 +2,7 @@ from __future__ import annotations -from settings.constants import ( +from settings.core.constants import ( ALL_PERMISSIONS, ALL_SCOPES, PERM_CREATE, @@ -39,9 +39,9 @@ async def test_view_page_literals_match_constants(self): them — this guards the literals against drifting from the constants.""" from pathlib import Path - from settings.constants import PAGE_BROWSE, PAGE_CREATE, PAGE_EDIT + from settings.core.constants import PAGE_BROWSE, PAGE_CREATE, PAGE_EDIT - views_path = Path(__file__).parent.parent / "settings" / "endpoints" / "views.py" + views_path = Path(__file__).parents[3] / "settings" / "web" / "endpoints" / "views.py" views = views_path.read_text() assert f'"{PAGE_BROWSE}"' in views assert f'"{PAGE_CREATE}"' in views diff --git a/modules/settings/tests/test_settings_secret_json_roundtrip.py b/modules/settings/tests/core/secrets/test_settings_secret_json_roundtrip.py similarity index 96% rename from modules/settings/tests/test_settings_secret_json_roundtrip.py rename to modules/settings/tests/core/secrets/test_settings_secret_json_roundtrip.py index 46b30cec7..5388a8e3a 100644 --- a/modules/settings/tests/test_settings_secret_json_roundtrip.py +++ b/modules/settings/tests/core/secrets/test_settings_secret_json_roundtrip.py @@ -11,7 +11,7 @@ from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict -from settings.reload import apply_changes_and_reload +from settings.module_settings.reload import apply_changes_and_reload from simple_module_core.settings_hydrate import build_settings diff --git a/modules/settings/tests/test_settings_secret_masking.py b/modules/settings/tests/core/secrets/test_settings_secret_masking.py similarity index 98% rename from modules/settings/tests/test_settings_secret_masking.py rename to modules/settings/tests/core/secrets/test_settings_secret_masking.py index ec175cf7a..a2c65c9be 100644 --- a/modules/settings/tests/test_settings_secret_masking.py +++ b/modules/settings/tests/core/secrets/test_settings_secret_masking.py @@ -12,9 +12,9 @@ from __future__ import annotations import pytest -from settings.constants import SENSITIVE_PLACEHOLDER from settings.contracts.schemas import SettingCreate, SettingScope -from settings.service import SettingService +from settings.core.constants import SENSITIVE_PLACEHOLDER +from settings.core.service import SettingService from simple_module_db.stored_settings import SYSTEM_SCOPE_ID pytestmark = pytest.mark.anyio diff --git a/modules/settings/tests/test_settings_secret_urls.py b/modules/settings/tests/core/secrets/test_settings_secret_urls.py similarity index 96% rename from modules/settings/tests/test_settings_secret_urls.py rename to modules/settings/tests/core/secrets/test_settings_secret_urls.py index 32eb1a3f0..50eaafaa8 100644 --- a/modules/settings/tests/test_settings_secret_urls.py +++ b/modules/settings/tests/core/secrets/test_settings_secret_urls.py @@ -13,7 +13,7 @@ import httpx import pytest from pydantic_settings import BaseSettings, SettingsConfigDict -from settings._module_settings import SECRET_MASK, _field_view +from settings.module_settings.discovery import SECRET_MASK, _field_view _INERTIA = {"X-Inertia": "true", "X-Inertia-Version": "1.0"} _CREATE = "/admin/settings/create" @@ -114,7 +114,7 @@ def test_a_masked_dsn_echoed_back_is_dropped(self): real DSN with a row of dots. The name rule does not know ``broker_url`` is a secret, so the field's rendered ``is_secret`` is what says the sentinel could be an echo.""" - from settings.endpoints.module_api import _strip_mask_sentinels + from settings.web.endpoints.module_api import _strip_mask_sentinels masked = frozenset({"broker_url"}) assert _strip_mask_sentinels(masked, {"broker_url": SECRET_MASK, "queue": "celery"}) == { @@ -125,7 +125,7 @@ def test_the_sentinel_is_stored_on_a_field_that_was_never_masked(self): """A field the editor rendered in clear text is not echoing anything, so a value that happens to equal the mask is a real edit. Dropping it on the sentinel alone silently discarded the write with nothing to show for it.""" - from settings.endpoints.module_api import _strip_mask_sentinels + from settings.web.endpoints.module_api import _strip_mask_sentinels changes = {"queue": SECRET_MASK} assert _strip_mask_sentinels(frozenset(), changes) == changes diff --git a/modules/settings/tests/test_store_secret_masking.py b/modules/settings/tests/core/secrets/test_settings_store_secret_masking.py similarity index 97% rename from modules/settings/tests/test_store_secret_masking.py rename to modules/settings/tests/core/secrets/test_settings_store_secret_masking.py index a2ba58210..60548ccf6 100644 --- a/modules/settings/tests/test_store_secret_masking.py +++ b/modules/settings/tests/core/secrets/test_settings_store_secret_masking.py @@ -13,10 +13,10 @@ import httpx import pytest from fastapi import FastAPI -from settings._row_masking import is_masked -from settings.constants import SENSITIVE_PLACEHOLDER from settings.contracts.schemas import SettingCreate, SettingScope -from settings.service import SettingService +from settings.core._row_masking import is_masked +from settings.core.constants import SENSITIVE_PLACEHOLDER +from settings.core.service import SettingService from simple_module_db.stored_settings import VALUE_TYPE_INT, VALUE_TYPE_STRING, Setting _DSN = "postgresql://svc:hunter2@db.internal/app" @@ -182,7 +182,7 @@ class TestTheMaskStopsAtTheScreen: """ async def test_the_store_reads_through_the_mask(self, db_session) -> None: - from settings.store import SettingsStore + from settings.module_settings.store import SettingsStore from simple_module_db.stored_settings import SYSTEM_SCOPE_ID service = SettingService(db_session) diff --git a/modules/settings/tests/test_settings_typed_secret_masking.py b/modules/settings/tests/core/secrets/test_settings_typed_secret_masking.py similarity index 95% rename from modules/settings/tests/test_settings_typed_secret_masking.py rename to modules/settings/tests/core/secrets/test_settings_typed_secret_masking.py index 0b2d0dc22..a9bbb5732 100644 --- a/modules/settings/tests/test_settings_typed_secret_masking.py +++ b/modules/settings/tests/core/secrets/test_settings_typed_secret_masking.py @@ -13,9 +13,9 @@ import pytest from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict -from settings._module_settings import SECRET_MASK -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.discovery import SECRET_MASK +from settings.module_settings.store import SettingsStore from simple_module_core.module_settings import register_module_settings from simple_module_core.secret_fields import annotation_holds_secret diff --git a/modules/settings/tests/test_settings_commit_invalidation.py b/modules/settings/tests/core/service/test_settings_commit_invalidation.py similarity index 93% rename from modules/settings/tests/test_settings_commit_invalidation.py rename to modules/settings/tests/core/service/test_settings_commit_invalidation.py index cd70c4f4e..1bbbc04db 100644 --- a/modules/settings/tests/test_settings_commit_invalidation.py +++ b/modules/settings/tests/core/service/test_settings_commit_invalidation.py @@ -2,9 +2,9 @@ import pytest from settings.contracts.events import SettingsReloaded -from settings.reload import apply_changes_and_reload -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.reload import apply_changes_and_reload +from settings.module_settings.store import SettingsStore from simple_module_core.invalidation import Invalidation from simple_module_db.transaction import finalize_session, rollback_session @@ -68,7 +68,7 @@ async def record(event): @pytest.mark.parametrize("commit", [False, True]) async def test_clearing_module_setting_waits_for_commit(app, commit): - from settings.endpoints.module_api import clear_module_field + from settings.web.endpoints.module_api import clear_module_field from starlette.requests import Request original = app.state.users.settings.allow_signup diff --git a/modules/settings/tests/test_concurrent_overrides.py b/modules/settings/tests/core/service/test_settings_concurrent_overrides.py similarity index 97% rename from modules/settings/tests/test_concurrent_overrides.py rename to modules/settings/tests/core/service/test_settings_concurrent_overrides.py index 226440235..2116ebc0f 100644 --- a/modules/settings/tests/test_concurrent_overrides.py +++ b/modules/settings/tests/core/service/test_settings_concurrent_overrides.py @@ -10,8 +10,8 @@ from __future__ import annotations import pytest -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore @pytest.mark.anyio diff --git a/modules/settings/tests/test_settings_default_clears_override.py b/modules/settings/tests/core/service/test_settings_default_clears_override.py similarity index 96% rename from modules/settings/tests/test_settings_default_clears_override.py rename to modules/settings/tests/core/service/test_settings_default_clears_override.py index f28dc823a..da5a7854e 100644 --- a/modules/settings/tests/test_settings_default_clears_override.py +++ b/modules/settings/tests/core/service/test_settings_default_clears_override.py @@ -4,8 +4,8 @@ import pytest from background_tasks.settings import DEFAULT_RETENTION_DAYS -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore URL = "/api/settings/modules" diff --git a/modules/settings/tests/test_settings_domain_errors.py b/modules/settings/tests/core/service/test_settings_domain_errors.py similarity index 58% rename from modules/settings/tests/test_settings_domain_errors.py rename to modules/settings/tests/core/service/test_settings_domain_errors.py index 84e023820..8f9309477 100644 --- a/modules/settings/tests/test_settings_domain_errors.py +++ b/modules/settings/tests/core/service/test_settings_domain_errors.py @@ -2,9 +2,9 @@ def test_settings_errors_share_a_module_base(): - from settings._managed_keys import ManagedKeyError - from settings._unique_write import DuplicateSettingError - from settings.exceptions import SettingsError + from settings.core._managed_keys import ManagedKeyError + from settings.core._unique_write import DuplicateSettingError + from settings.core.exceptions import SettingsError assert isinstance(ManagedKeyError("key", "/clear"), SettingsError) assert isinstance(DuplicateSettingError("key"), SettingsError) diff --git a/modules/settings/tests/test_duplicate_and_race.py b/modules/settings/tests/core/service/test_settings_duplicate_and_race.py similarity index 98% rename from modules/settings/tests/test_duplicate_and_race.py rename to modules/settings/tests/core/service/test_settings_duplicate_and_race.py index 8349eabe9..e1576773d 100644 --- a/modules/settings/tests/test_duplicate_and_race.py +++ b/modules/settings/tests/core/service/test_settings_duplicate_and_race.py @@ -11,7 +11,7 @@ import pytest from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db from simple_module_db.stored_settings import Base, Setting diff --git a/modules/settings/tests/test_settings_generic_module_guard.py b/modules/settings/tests/core/service/test_settings_generic_module_guard.py similarity index 98% rename from modules/settings/tests/test_settings_generic_module_guard.py rename to modules/settings/tests/core/service/test_settings_generic_module_guard.py index aaaf60dae..7f753e526 100644 --- a/modules/settings/tests/test_settings_generic_module_guard.py +++ b/modules/settings/tests/core/service/test_settings_generic_module_guard.py @@ -50,7 +50,7 @@ def test_browse_does_not_suggest_module_keys(): from types import SimpleNamespace from fastapi import FastAPI - from settings.known_keys import build + from settings.web.known_keys import build from simple_module_core.module_settings import register_module_settings from users.settings import UsersSettings diff --git a/modules/settings/tests/test_settings_group_metadata.py b/modules/settings/tests/core/service/test_settings_group_metadata.py similarity index 88% rename from modules/settings/tests/test_settings_group_metadata.py rename to modules/settings/tests/core/service/test_settings_group_metadata.py index 5bf50ee5f..893c67a80 100644 --- a/modules/settings/tests/test_settings_group_metadata.py +++ b/modules/settings/tests/core/service/test_settings_group_metadata.py @@ -3,7 +3,7 @@ import pytest from pydantic import Field from pydantic_settings import BaseSettings -from settings._module_settings import _field_view +from settings.module_settings.discovery import _field_view @pytest.mark.parametrize("group", [False, 42, [], {}, None, "SMTP"]) @@ -19,7 +19,7 @@ class Config(BaseSettings): def test_string_literal_field_is_a_string_select(): from typing import Literal - from settings._field_meta import choices_for + from settings.module_settings._field_meta import choices_for from simple_module_core.settings_hydrate import input_type_for_field, value_type_for_field class Config(BaseSettings): diff --git a/modules/settings/tests/test_settings_service.py b/modules/settings/tests/core/service/test_settings_service.py similarity index 99% rename from modules/settings/tests/test_settings_service.py rename to modules/settings/tests/core/service/test_settings_service.py index 31c7903a2..0b9cccd9a 100644 --- a/modules/settings/tests/test_settings_service.py +++ b/modules/settings/tests/core/service/test_settings_service.py @@ -8,7 +8,7 @@ SettingUpdate, SettingUpsert, ) -from settings.service import SettingService +from settings.core.service import SettingService from simple_module_db.stored_settings import SYSTEM_SCOPE_ID from sqlalchemy.ext.asyncio import AsyncSession diff --git a/modules/settings/tests/test_tenant_settings.py b/modules/settings/tests/core/tenant/test_settings_tenant_settings.py similarity index 98% rename from modules/settings/tests/test_tenant_settings.py rename to modules/settings/tests/core/tenant/test_settings_tenant_settings.py index ba0f61b10..45160772f 100644 --- a/modules/settings/tests/test_tenant_settings.py +++ b/modules/settings/tests/core/tenant/test_settings_tenant_settings.py @@ -10,12 +10,12 @@ import pytest from fastapi import Request -from settings.constants import INVALIDATION_CHANNEL, PERM_TENANT_EDIT from settings.contracts.invalidation import parse_invalidation_key from settings.contracts.registry import SettingDefinition from settings.contracts.schemas import SettingScope, SettingUpsert, SettingValueType -from settings.deps import SettingsDep -from settings.service import SettingService +from settings.core.constants import INVALIDATION_CHANNEL, PERM_TENANT_EDIT +from settings.core.service import SettingService +from settings.web.deps import SettingsDep from simple_module_core.tenancy import TenantRole, tenant_role CURRENT = "/api/settings/tenant/current" diff --git a/modules/settings/tests/test_env_pinned.py b/modules/settings/tests/module_settings/fields/test_settings_env_pinned.py similarity index 92% rename from modules/settings/tests/test_env_pinned.py rename to modules/settings/tests/module_settings/fields/test_settings_env_pinned.py index 0a969bf39..a044ad170 100644 --- a/modules/settings/tests/test_env_pinned.py +++ b/modules/settings/tests/module_settings/fields/test_settings_env_pinned.py @@ -13,11 +13,11 @@ from fastapi import FastAPI from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict -from settings.reload import EnvPinnedError, apply_changes_and_reload -from settings.service import SettingService -from settings.services import SettingsServices -from settings.settings import SettingsSettings -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.core.services import SettingsServices +from settings.core.settings import SettingsSettings +from settings.module_settings.reload import EnvPinnedError, apply_changes_and_reload +from settings.module_settings.store import SettingsStore from simple_module_core.events import EventBus from simple_module_core.module_settings import register_module_settings from simple_module_core.settings_hydrate import env_supplied_fields @@ -72,7 +72,7 @@ async def test_a_secret_is_stored_as_submitted_not_as_its_dump(pinned_app, db_se def test_a_secrets_file_value_is_labelled_env(tmp_path): """Not in ``os.environ``, but hydration still lets it win — so must the label.""" - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view (tmp_path / "SM_TEST_PINNED_QUEUE").write_text("from-secret") diff --git a/modules/settings/tests/test_settings_env_unprefixed.py b/modules/settings/tests/module_settings/fields/test_settings_env_unprefixed.py similarity index 100% rename from modules/settings/tests/test_settings_env_unprefixed.py rename to modules/settings/tests/module_settings/fields/test_settings_env_unprefixed.py diff --git a/modules/settings/tests/test_settings_field_sources.py b/modules/settings/tests/module_settings/fields/test_settings_field_sources.py similarity index 93% rename from modules/settings/tests/test_settings_field_sources.py rename to modules/settings/tests/module_settings/fields/test_settings_field_sources.py index 233a576a1..2e1ee1b64 100644 --- a/modules/settings/tests/test_settings_field_sources.py +++ b/modules/settings/tests/module_settings/fields/test_settings_field_sources.py @@ -8,7 +8,7 @@ from __future__ import annotations import pytest -from settings._module_settings import ModuleSettingField +from settings.module_settings.discovery import ModuleSettingField def _first_field(instance) -> str: @@ -59,7 +59,7 @@ async def test_fields_carry_their_source(self, authenticated_client): def test_env_var_presence_is_detected(self, monkeypatch: pytest.MonkeyPatch): """A class that really reads env must not read as 'Default'.""" from pydantic_settings import BaseSettings, SettingsConfigDict - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view from simple_module_core.settings_hydrate import env_supplied_fields class _EnvBacked(BaseSettings): @@ -81,7 +81,7 @@ def test_unread_env_var_is_not_claimed_as_the_source(self, monkeypatch: pytest.M plus DB overrides — so a leftover ``SM_*`` var changes nothing. Badging it "From environment" would invert the question this screen answers.""" from file_storage.settings import FileStorageSettings - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view name = _first_field(FileStorageSettings()) monkeypatch.setenv(f"SM_FILE_STORAGE_{name.upper()}", "definitely-not-a-backend") @@ -93,7 +93,7 @@ def test_unread_env_var_is_not_claimed_as_the_source(self, monkeypatch: pytest.M def test_overrides_mark_their_fields(self): from file_storage.settings import FileStorageSettings - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view instance = FileStorageSettings() name = _first_field(instance) @@ -135,7 +135,7 @@ class TestSecretMaskingIsTypeAware: """ def _field(self, cls, name: str): - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view return _field_view(name, cls(), "SM_USERS_", frozenset()) @@ -148,7 +148,7 @@ def test_an_int_named_like_a_secret_is_not_masked(self): assert isinstance(field.value, int) def test_a_real_string_secret_is_still_masked(self): - from settings._module_settings import SECRET_MASK + from settings.module_settings.discovery import SECRET_MASK from users.settings import UsersSettings field = self._field(UsersSettings, "reset_password_token_secret") @@ -164,7 +164,7 @@ def test_an_optional_string_secret_stays_masked(self): "string" would have silently exposed it. """ from pydantic_settings import BaseSettings - from settings._module_settings import SECRET_MASK, _field_view + from settings.module_settings.discovery import SECRET_MASK, _field_view class _OptionalSecret(BaseSettings): smtp_password: str | None = "hunter2" diff --git a/modules/settings/tests/test_lenient_related_fields_validators.py b/modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py similarity index 100% rename from modules/settings/tests/test_lenient_related_fields_validators.py rename to modules/settings/tests/module_settings/fields/test_settings_lenient_related_fields_validators.py diff --git a/modules/settings/tests/test_module_settings.py b/modules/settings/tests/module_settings/fields/test_settings_module_settings.py similarity index 91% rename from modules/settings/tests/test_module_settings.py rename to modules/settings/tests/module_settings/fields/test_settings_module_settings.py index 2562090e5..786fe0103 100644 --- a/modules/settings/tests/test_module_settings.py +++ b/modules/settings/tests/module_settings/fields/test_settings_module_settings.py @@ -5,9 +5,9 @@ from fastapi import FastAPI from pydantic import Field from pydantic_settings import BaseSettings -from settings._module_settings import collect_module_settings -from settings.services import SettingsServices -from settings.settings import SettingsSettings +from settings.core.services import SettingsServices +from settings.core.settings import SettingsSettings +from settings.module_settings.discovery import collect_module_settings from simple_module_core.module import ModuleMeta from simple_module_core.services import Services diff --git a/modules/settings/tests/test_module_settings_render.py b/modules/settings/tests/module_settings/fields/test_settings_module_settings_render.py similarity index 100% rename from modules/settings/tests/test_module_settings_render.py rename to modules/settings/tests/module_settings/fields/test_settings_module_settings_render.py diff --git a/modules/settings/tests/test_module_settings_serialize.py b/modules/settings/tests/module_settings/fields/test_settings_module_settings_serialize.py similarity index 96% rename from modules/settings/tests/test_module_settings_serialize.py rename to modules/settings/tests/module_settings/fields/test_settings_module_settings_serialize.py index 9f50ceddc..e7ab9c1c7 100644 --- a/modules/settings/tests/test_module_settings_serialize.py +++ b/modules/settings/tests/module_settings/fields/test_settings_module_settings_serialize.py @@ -17,11 +17,11 @@ from typing import Any import pytest -from settings._module_settings import ( +from settings.module_settings.discovery import ( ModuleSettingField, ModuleSettingsView, ) -from settings._module_settings_props import serialize +from settings.module_settings.props import serialize def _view_with(value: Any, default: Any = "") -> ModuleSettingsView: diff --git a/modules/settings/tests/test_testable_checks.py b/modules/settings/tests/module_settings/fields/test_settings_testable_checks.py similarity index 100% rename from modules/settings/tests/test_testable_checks.py rename to modules/settings/tests/module_settings/fields/test_settings_testable_checks.py diff --git a/modules/settings/tests/test_hydrate.py b/modules/settings/tests/module_settings/hydrate/test_settings_hydrate.py similarity index 98% rename from modules/settings/tests/test_hydrate.py rename to modules/settings/tests/module_settings/hydrate/test_settings_hydrate.py index b8b05f2da..31368bda3 100644 --- a/modules/settings/tests/test_hydrate.py +++ b/modules/settings/tests/module_settings/hydrate/test_settings_hydrate.py @@ -5,8 +5,8 @@ import pytest from pydantic import ValidationError, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings, value_type_for_field diff --git a/modules/settings/tests/test_hydrate_related_fields.py b/modules/settings/tests/module_settings/hydrate/test_settings_hydrate_related_fields.py similarity index 100% rename from modules/settings/tests/test_hydrate_related_fields.py rename to modules/settings/tests/module_settings/hydrate/test_settings_hydrate_related_fields.py diff --git a/modules/settings/tests/test_module_registry.py b/modules/settings/tests/module_settings/hydrate/test_settings_module_registry.py similarity index 100% rename from modules/settings/tests/test_module_registry.py rename to modules/settings/tests/module_settings/hydrate/test_settings_module_registry.py diff --git a/modules/settings/tests/test_registration.py b/modules/settings/tests/module_settings/hydrate/test_settings_registration.py similarity index 93% rename from modules/settings/tests/test_registration.py rename to modules/settings/tests/module_settings/hydrate/test_settings_registration.py index 070799471..d96053def 100644 --- a/modules/settings/tests/test_registration.py +++ b/modules/settings/tests/module_settings/hydrate/test_settings_registration.py @@ -5,8 +5,8 @@ import pytest from fastapi import FastAPI from pydantic_settings import BaseSettings -from settings.services import SettingsServices -from settings.settings import SettingsSettings +from settings.core.services import SettingsServices +from settings.core.settings import SettingsSettings from simple_module_core.module_settings import module_settings_registry, register_module_settings diff --git a/modules/settings/tests/test_reload.py b/modules/settings/tests/module_settings/hydrate/test_settings_reload.py similarity index 92% rename from modules/settings/tests/test_reload.py rename to modules/settings/tests/module_settings/hydrate/test_settings_reload.py index a2fe1c376..114141bd0 100644 --- a/modules/settings/tests/test_reload.py +++ b/modules/settings/tests/module_settings/hydrate/test_settings_reload.py @@ -7,11 +7,11 @@ from pydantic import ValidationError from pydantic_settings import BaseSettings from settings.contracts.events import SettingsReloaded -from settings.reload import apply_changes_and_reload -from settings.service import SettingService -from settings.services import SettingsServices -from settings.settings import SettingsSettings -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.core.services import SettingsServices +from settings.core.settings import SettingsSettings +from settings.module_settings.reload import apply_changes_and_reload +from settings.module_settings.store import SettingsStore from simple_module_core.events import EventBus from simple_module_core.module_settings import register_module_settings from simple_module_db.transaction import finalize_session diff --git a/modules/settings/tests/test_store.py b/modules/settings/tests/module_settings/store/test_settings_store.py similarity index 95% rename from modules/settings/tests/test_store.py rename to modules/settings/tests/module_settings/store/test_settings_store.py index eb7651d73..09afce328 100644 --- a/modules/settings/tests/test_store.py +++ b/modules/settings/tests/module_settings/store/test_settings_store.py @@ -3,8 +3,8 @@ from __future__ import annotations import pytest -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore @pytest.mark.asyncio diff --git a/modules/settings/tests/test_store_filters.py b/modules/settings/tests/module_settings/store/test_settings_store_filters.py similarity index 98% rename from modules/settings/tests/test_store_filters.py rename to modules/settings/tests/module_settings/store/test_settings_store_filters.py index 3128091ed..657efdb55 100644 --- a/modules/settings/tests/test_store_filters.py +++ b/modules/settings/tests/module_settings/store/test_settings_store_filters.py @@ -12,7 +12,7 @@ import httpx import pytest from fastapi import FastAPI -from settings.browse_query import PageParam, PerPageParam +from settings.web.browse_query import PageParam, PerPageParam _INERTIA = {"X-Inertia": "true", "X-Inertia-Version": "1.0"} _STORE = "/admin/settings/store" @@ -175,7 +175,7 @@ class TestServiceQuery: """The service owns the query; the view only clamps and shapes it.""" async def test_search_and_page_return_rows_and_the_unpaged_total(self, db_session) -> None: - from settings.service import SettingService + from settings.core.service import SettingService from simple_module_db.stored_settings import Setting for i in range(5): @@ -190,7 +190,7 @@ async def test_search_and_page_return_rows_and_the_unpaged_total(self, db_sessio assert [r.key for r in rows] == ["a.key_2", "a.key_3"] async def test_counts_by_scope_names_every_scope_even_at_zero(self, db_session) -> None: - from settings.service import SettingService + from settings.core.service import SettingService from simple_module_db.stored_settings import Setting db_session.add(Setting(scope="system", scope_id="", key="a.b", value="v")) diff --git a/modules/settings/tests/test_settings_api.py b/modules/settings/tests/web/api/test_settings_api.py similarity index 99% rename from modules/settings/tests/test_settings_api.py rename to modules/settings/tests/web/api/test_settings_api.py index 52c585874..5824a3ecb 100644 --- a/modules/settings/tests/test_settings_api.py +++ b/modules/settings/tests/web/api/test_settings_api.py @@ -4,7 +4,7 @@ import httpx import pytest -from settings.constants import ( +from settings.core.constants import ( API_PREFIX, STATUS_CREATED, STATUS_NO_CONTENT, diff --git a/modules/settings/tests/test_clear_route_per_scope.py b/modules/settings/tests/web/api/test_settings_clear_route_per_scope.py similarity index 95% rename from modules/settings/tests/test_clear_route_per_scope.py rename to modules/settings/tests/web/api/test_settings_clear_route_per_scope.py index 02ac3c030..8390b4d6e 100644 --- a/modules/settings/tests/test_clear_route_per_scope.py +++ b/modules/settings/tests/web/api/test_settings_clear_route_per_scope.py @@ -3,10 +3,10 @@ from __future__ import annotations import pytest -from settings._managed_keys import ManagedKeyError from settings.contracts.registry import SettingDefinition, clear_route from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core._managed_keys import ManagedKeyError +from settings.core.service import SettingService from simple_module_db.stored_settings import SYSTEM_SCOPE_ID KEY = "demo.scoped_logo" diff --git a/modules/settings/tests/test_known_keys_meta.py b/modules/settings/tests/web/api/test_settings_known_keys_meta.py similarity index 94% rename from modules/settings/tests/test_known_keys_meta.py rename to modules/settings/tests/web/api/test_settings_known_keys_meta.py index de4f0a440..09e52b569 100644 --- a/modules/settings/tests/test_known_keys_meta.py +++ b/modules/settings/tests/web/api/test_settings_known_keys_meta.py @@ -53,8 +53,8 @@ async def test_generic_suggestions_carry_the_full_meta_shape(self, app, authenti } <= set(entry) async def test_secret_generic_defaults_are_masked(self, app, authenticated_client): - from settings._module_settings import SECRET_MASK from settings.contracts.registry import SettingDefinition + from settings.module_settings.discovery import SECRET_MASK app.state.settings.registry.add( SettingDefinition(key="orders.api_key", default="private-credential") @@ -136,7 +136,7 @@ class TestEnvValueMasking: """ def test_a_non_secret_env_value_is_shown(self, monkeypatch: pytest.MonkeyPatch) -> None: - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view monkeypatch.setenv("SM_DEMO_HOST", "mail.example.com") @@ -146,7 +146,7 @@ def test_a_non_secret_env_value_is_shown(self, monkeypatch: pytest.MonkeyPatch) assert view.env_value == "mail.example.com" def test_a_secret_env_value_is_masked(self, monkeypatch: pytest.MonkeyPatch) -> None: - from settings._module_settings import SECRET_MASK, _field_view + from settings.module_settings.discovery import SECRET_MASK, _field_view monkeypatch.setenv("SM_DEMO_API_KEY", "super-secret-value") @@ -156,7 +156,7 @@ def test_a_secret_env_value_is_masked(self, monkeypatch: pytest.MonkeyPatch) -> assert view.env_value == SECRET_MASK def test_an_unset_env_var_has_no_value_at_all(self, monkeypatch: pytest.MonkeyPatch) -> None: - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view monkeypatch.delenv("SM_DEMO_HOST", raising=False) @@ -169,7 +169,7 @@ def test_an_unset_env_var_has_no_value_at_all(self, monkeypatch: pytest.MonkeyPa def test_a_db_backed_class_reports_no_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None: """``MailSettings`` declares no ``env_prefix``: the var is not a fallback.""" from mail.core.settings import MailSettings - from settings._module_settings import _field_view + from settings.module_settings.discovery import _field_view monkeypatch.setenv("SM_MAIL_SMTP_HOST", "not-a-fallback.example.com") diff --git a/modules/settings/tests/test_module_api.py b/modules/settings/tests/web/api/test_settings_module_api.py similarity index 96% rename from modules/settings/tests/test_module_api.py rename to modules/settings/tests/web/api/test_settings_module_api.py index 63897b4c5..66892ef56 100644 --- a/modules/settings/tests/test_module_api.py +++ b/modules/settings/tests/web/api/test_settings_module_api.py @@ -36,8 +36,8 @@ async def test_put_validation_error_surfaces_422(authenticated_client, app): async def test_put_rejects_and_does_not_persist_unbounded_background_task_values( authenticated_client, app ): - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore original = app.state.background_tasks.settings resp = await authenticated_client.put( diff --git a/modules/settings/tests/test_module_api_json_fields.py b/modules/settings/tests/web/api/test_settings_module_api_json_fields.py similarity index 97% rename from modules/settings/tests/test_module_api_json_fields.py rename to modules/settings/tests/web/api/test_settings_module_api_json_fields.py index 103b2e8ab..422c3d1d7 100644 --- a/modules/settings/tests/test_module_api_json_fields.py +++ b/modules/settings/tests/web/api/test_settings_module_api_json_fields.py @@ -8,8 +8,8 @@ from __future__ import annotations from background_tasks.settings import BackgroundTasksSettings -from settings.service import SettingService -from settings.store import SettingsStore +from settings.core.service import SettingService +from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings, input_type_for_field _PKG = "background_tasks" diff --git a/modules/settings/tests/test_upload_key_delete.py b/modules/settings/tests/web/api/test_settings_upload_key_delete.py similarity index 98% rename from modules/settings/tests/test_upload_key_delete.py rename to modules/settings/tests/web/api/test_settings_upload_key_delete.py index 9e6a6dbee..48bcb0bfa 100644 --- a/modules/settings/tests/test_upload_key_delete.py +++ b/modules/settings/tests/web/api/test_settings_upload_key_delete.py @@ -10,10 +10,10 @@ from __future__ import annotations import pytest -from settings._managed_keys import ManagedKeyError from settings.contracts.registry import SettingDefinition from settings.contracts.schemas import SettingScope, SettingUpsert -from settings.service import SettingService +from settings.core._managed_keys import ManagedKeyError +from settings.core.service import SettingService from simple_module_db.stored_settings import SYSTEM_SCOPE_ID KEY = "demo.logo" diff --git a/modules/settings/tests/test_settings_view_authz.py b/modules/settings/tests/web/api/test_settings_view_authz.py similarity index 100% rename from modules/settings/tests/test_settings_view_authz.py rename to modules/settings/tests/web/api/test_settings_view_authz.py diff --git a/modules/site_lock/site_lock/core/settings.py b/modules/site_lock/site_lock/core/settings.py index 01afc3f6b..d3b887aea 100644 --- a/modules/site_lock/site_lock/core/settings.py +++ b/modules/site_lock/site_lock/core/settings.py @@ -1,7 +1,7 @@ """Site Lock module settings — DB-backed via ``register_module_settings``. The field is named ``password`` so the settings admin UI masks it -automatically (``settings._module_settings.is_secret_field``). +automatically (``settings.module_settings.discovery.is_secret_field``). """ from __future__ import annotations diff --git a/modules/site_lock/site_lock/core/state.py b/modules/site_lock/site_lock/core/state.py index a14d09955..591b72086 100644 --- a/modules/site_lock/site_lock/core/state.py +++ b/modules/site_lock/site_lock/core/state.py @@ -21,7 +21,7 @@ def _default_limiter() -> AttemptLimiter: class SiteLockState: """Per-app site-lock state. - ``settings`` is reassigned in place by ``settings.reload`` on a hot + ``settings`` is reassigned in place by ``settings.module_settings.reload`` on a hot reload, so this dataclass must stay mutable. ``limiter`` survives those reloads, which is what keeps an in-flight brute-force cooldown from being cleared by an unrelated settings save. diff --git a/modules/site_lock/tests/core/settings/test_site_lock_settings.py b/modules/site_lock/tests/core/settings/test_site_lock_settings.py index 9aa9d8b89..7a977123e 100644 --- a/modules/site_lock/tests/core/settings/test_site_lock_settings.py +++ b/modules/site_lock/tests/core/settings/test_site_lock_settings.py @@ -44,6 +44,6 @@ def test_blank_password_error_is_pinned_to_the_enabled_field(password: str) -> N def test_password_field_is_masked_by_settings_ui() -> None: # The admin UI masks fields by name; `password` must match that regex. - from settings._module_settings import is_secret_field + from settings.module_settings.discovery import is_secret_field assert is_secret_field("password") is True diff --git a/modules/tenants/tenants/web/endpoints/views.py b/modules/tenants/tenants/web/endpoints/views.py index 704f067d8..e7c30dc6f 100644 --- a/modules/tenants/tenants/web/endpoints/views.py +++ b/modules/tenants/tenants/web/endpoints/views.py @@ -118,9 +118,9 @@ async def tenant_settings( rows and the writes belong to ``settings`` (``/api/settings/tenant/current``), which acts on the active tenant only. """ - from settings.deps import get_setting_service - from settings.tenant_scope import registry_of - from settings.tenant_view import list_for_tenant + from settings.core.tenant_scope import registry_of + from settings.core.tenant_view import list_for_tenant + from settings.web.deps import get_setting_service tenant_id = getattr(request.state, "tenant_id", None) tenant = await service.get(tenant_id) if tenant_id else None diff --git a/modules/users/tests/_users_app_builders.py b/modules/users/tests/_users_app_builders.py index 5a541bb10..b38667b13 100644 --- a/modules/users/tests/_users_app_builders.py +++ b/modules/users/tests/_users_app_builders.py @@ -99,8 +99,8 @@ async def _seed_users_settings( Hydrate fires inside the lifespan ``__aenter__``, so this needs to land between table creation and lifespan entry. """ - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore async with application.state.sm.db.session_factory() as session: store = SettingsStore(SettingService(session)) diff --git a/modules/users/tests/test_settings.py b/modules/users/tests/test_settings.py index ddc3d2aab..01ff3859a 100644 --- a/modules/users/tests/test_settings.py +++ b/modules/users/tests/test_settings.py @@ -62,8 +62,8 @@ class TestUsersSettingsDbOverrides: @pytest.mark.asyncio async def test_allow_signup_override_from_db(self, db_session): - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings from users.settings import UsersSettings @@ -75,8 +75,8 @@ async def test_allow_signup_override_from_db(self, db_session): @pytest.mark.asyncio async def test_base_url_override_from_db(self, db_session): - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore from simple_module_core.settings_hydrate import hydrate_settings from users.settings import UsersSettings diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 3fae5ea33..aef6eec1a 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -1,7 +1,7 @@ """Users module settings — DB-backed via ``register_module_settings``. Most fields hydrate from the DB after boot, with pydantic defaults filling in -until then. Runtime changes go through ``settings.reload.apply_changes_and_reload``. +until then. Runtime changes go through ``settings.module_settings.reload.apply_changes_and_reload``. The two token-secret fields read ``SM_USERS_*`` at import time as a bootstrap path: a fresh production deploy needs to clear the validator below before any diff --git a/tests/test_hydration_lifespan.py b/tests/test_hydration_lifespan.py index 1826c3155..14fa07850 100644 --- a/tests/test_hydration_lifespan.py +++ b/tests/test_hydration_lifespan.py @@ -6,8 +6,8 @@ @pytest.mark.asyncio async def test_lifespan_hydrates_host_settings_from_db(app, db_session): """After lifespan starts, host settings reflect DB overrides, not defaults.""" - from settings.service import SettingService - from settings.store import SettingsStore + from settings.core.service import SettingService + from settings.module_settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) await store.set_override("host", "multi_tenant", "true", "bool") From 91cbf97564d935077c64fdeaaecd557dee977817 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 02:25:25 +0100 Subject: [PATCH 2/3] docs(settings): state which old paths warn and which moved without alias Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 2 +- docs/framework/settings.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b605c207a..0851d8b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,7 +92,7 @@ All notable changes to this project are documented in this file. The format is b - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. - `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. -- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.store`, `settings.reload`, `settings.constants`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged. +- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.store`, `settings.reload`, `settings.constants`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46; `settings.errors`, `settings.exceptions`, `settings.services`, `settings.settings`, `settings.env_vars`, `settings.invalidation`, `settings.known_keys`, `settings.browse_query`, `settings._secrets` and `settings.endpoints.{api,views,tenant_api}` moved with no alias. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged. - `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain. - `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46. - `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.host_resolver`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. diff --git a/docs/framework/settings.md b/docs/framework/settings.md index d70818b90..c9f11c8cd 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -110,9 +110,11 @@ reload and the generic key/value API on top. The old plugin paths (`settings.registration`, `settings.module_registry`, `settings.hydrate`) still re-export these and emit a `DeprecationWarning`; -`settings.models`, `settings.core._secrets` and the moved names in -`settings.module_settings.store` / `settings.core.constants` re-export them silently. They go no -earlier than 0.1.0. +`settings.models` and the moved names in `settings.module_settings.store` / +`settings.core.constants` (and `settings.core._secrets`) re-export them +silently; those re-exports go no earlier than 0.1.0. The old module paths +`settings.store` and `settings.constants` now warn until 0.0.46, and +`settings._secrets` moved to `settings.core._secrets` with no alias. A Celery worker or CLI script never runs the lifespan, so it hydrates synchronously: From a8aa3ff28cb4282ee73e2feed6233f4946a0786c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 6 Nov 2026 02:33:44 +0100 Subject: [PATCH 3/3] fix(settings): keep settings.store, settings.constants and settings._secrets until 0.1.0 docs/framework/settings.md promised these re-exports no earlier than 0.1.0, so their layout aliases expire then instead of 0.0.46. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- CHANGELOG.md | 2 +- docs/framework/settings.md | 8 ++++---- docs/module-layout.md | 2 +- modules/settings/settings/_secrets.py | 11 +++++++++++ modules/settings/settings/constants.py | 4 ++-- modules/settings/settings/store.py | 4 ++-- 6 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 modules/settings/settings/_secrets.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0851d8b24..0ac3e6c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,7 +92,7 @@ All notable changes to this project are documented in this file. The format is b - `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. - `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46. - `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged. -- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.store`, `settings.reload`, `settings.constants`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46; `settings.errors`, `settings.exceptions`, `settings.services`, `settings.settings`, `settings.env_vars`, `settings.invalidation`, `settings.known_keys`, `settings.browse_query`, `settings._secrets` and `settings.endpoints.{api,views,tenant_api}` moved with no alias. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged. +- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.reload`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46, and `settings.store`, `settings.constants` and `settings._secrets` until 0.1.0 (the release their re-exports were already promised for); `settings.errors`, `settings.exceptions`, `settings.services`, `settings.settings`, `settings.env_vars`, `settings.invalidation`, `settings.known_keys`, `settings.browse_query` and `settings.endpoints.{api,views,tenant_api}` moved with no alias. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged. - `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain. - `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46. - `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.host_resolver`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged. diff --git a/docs/framework/settings.md b/docs/framework/settings.md index c9f11c8cd..d2eabf498 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -111,10 +111,10 @@ reload and the generic key/value API on top. The old plugin paths (`settings.registration`, `settings.module_registry`, `settings.hydrate`) still re-export these and emit a `DeprecationWarning`; `settings.models` and the moved names in `settings.module_settings.store` / -`settings.core.constants` (and `settings.core._secrets`) re-export them -silently; those re-exports go no earlier than 0.1.0. The old module paths -`settings.store` and `settings.constants` now warn until 0.0.46, and -`settings._secrets` moved to `settings.core._secrets` with no alias. +`settings.core.constants` / `settings.core._secrets` re-export them silently. +The old module paths `settings.store`, `settings.constants` and +`settings._secrets` are aliases of those modules that warn on import. All of +these go no earlier than 0.1.0. A Celery worker or CLI script never runs the lifespan, so it hydrates synchronously: diff --git a/docs/module-layout.md b/docs/module-layout.md index 25e5458b5..e06c219b4 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -224,7 +224,7 @@ when a later module PR moves files. | mail | 2 (+2 shims) | 0 | follows this layout | | oidc | 3 (+3 shims) | 0 | follows this layout | | permissions | 3 (+2 shims) | 0 | follows this layout | -| settings | 4 (+7 shims, +3 deprecated re-exports) | 0 | follows this layout | +| settings | 4 (+8 shims, +3 deprecated re-exports) | 0 | follows this layout | | sharing | 3 (+3 shims) | 0 | follows this layout | | site_lock | 2 (+1 shim) | 0 | follows this layout | | tenants | 3 (+6 shims) | 0 | follows this layout | diff --git a/modules/settings/settings/_secrets.py b/modules/settings/settings/_secrets.py new file mode 100644 index 000000000..954195a44 --- /dev/null +++ b/modules/settings/settings/_secrets.py @@ -0,0 +1,11 @@ +"""Moved to :mod:`settings.core._secrets`; this alias goes in 0.1.0 (module layout).""" + +from __future__ import annotations + +import importlib +import sys + +from simple_module_core.deprecation import deprecated_module + +deprecated_module("settings._secrets", remove_in="0.1.0", use="settings.core._secrets") +sys.modules[__name__] = importlib.import_module("settings.core._secrets") diff --git a/modules/settings/settings/constants.py b/modules/settings/settings/constants.py index 7543a291a..f8eccb3a5 100644 --- a/modules/settings/settings/constants.py +++ b/modules/settings/settings/constants.py @@ -1,4 +1,4 @@ -"""Moved to :mod:`settings.core.constants`; this alias goes in 0.0.46 (module layout).""" +"""Moved to :mod:`settings.core.constants`; this alias goes in 0.1.0 (module layout).""" from __future__ import annotations @@ -7,5 +7,5 @@ from simple_module_core.deprecation import deprecated_module -deprecated_module("settings.constants", remove_in="0.0.46", use="settings.core.constants") +deprecated_module("settings.constants", remove_in="0.1.0", use="settings.core.constants") sys.modules[__name__] = importlib.import_module("settings.core.constants") diff --git a/modules/settings/settings/store.py b/modules/settings/settings/store.py index e8e43dcf1..7fe5bbcd9 100644 --- a/modules/settings/settings/store.py +++ b/modules/settings/settings/store.py @@ -1,4 +1,4 @@ -"""Moved to :mod:`settings.module_settings.store`; this alias goes in 0.0.46 (module layout).""" +"""Moved to :mod:`settings.module_settings.store`; this alias goes in 0.1.0 (module layout).""" from __future__ import annotations @@ -7,5 +7,5 @@ from simple_module_core.deprecation import deprecated_module -deprecated_module("settings.store", remove_in="0.0.46", use="settings.module_settings.store") +deprecated_module("settings.store", remove_in="0.1.0", use="settings.module_settings.store") sys.modules[__name__] = importlib.import_module("settings.module_settings.store")