diff --git a/host/migrations/versions/1fe7590fc594_add_settings_tables.py b/host/migrations/versions/1fe7590fc594_add_settings_tables.py new file mode 100644 index 00000000..a0502504 --- /dev/null +++ b/host/migrations/versions/1fe7590fc594_add_settings_tables.py @@ -0,0 +1,81 @@ +"""add settings tables + +Revision ID: 1fe7590fc594 +Revises: b7e1af4c9d02 +Create Date: 2026-04-19 07:46:59.090748 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1fe7590fc594" +down_revision: str | None = "b7e1af4c9d02" +branch_labels: str | Sequence[str] | None = ("settings",) +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # On PostgreSQL, create the `settings` schema before creating tables. + if op.get_context().dialect.name == "postgresql": + op.execute("CREATE SCHEMA IF NOT EXISTS settings") + + op.create_table( + "settings_setting", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "scope", + sa.String(length=10), + nullable=False, + server_default=sa.text("'system'"), + ), + sa.Column( + "scope_id", + sa.String(length=255), + nullable=False, + server_default=sa.text("''"), + ), + sa.Column("key", sa.String(length=200), nullable=False), + sa.Column("value", sa.String(length=4000), nullable=False), + sa.Column( + "value_type", + sa.String(length=10), + nullable=False, + server_default=sa.text("'string'"), + ), + sa.Column("description", sa.String(length=2000), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_settings_setting")), + sa.UniqueConstraint( + "scope", "scope_id", "key", name="uq_settings_setting_scope_scope_id_key" + ), + ) + op.create_index(op.f("ix_settings_setting_scope"), "settings_setting", ["scope"], unique=False) + op.create_index( + op.f("ix_settings_setting_scope_id"), + "settings_setting", + ["scope_id"], + unique=False, + ) + op.create_index(op.f("ix_settings_setting_key"), "settings_setting", ["key"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_settings_setting_key"), table_name="settings_setting") + op.drop_index(op.f("ix_settings_setting_scope_id"), table_name="settings_setting") + op.drop_index(op.f("ix_settings_setting_scope"), table_name="settings_setting") + op.drop_table("settings_setting") + + # On PostgreSQL, drop the `settings` schema. + if op.get_context().dialect.name == "postgresql": + op.execute("DROP SCHEMA IF EXISTS settings") diff --git a/host/pyproject.toml b/host/pyproject.toml index aedc5748..38e4b956 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "permissions", "background-tasks", "file-storage", + "settings", "python-multipart>=0.0.6", ] @@ -22,3 +23,4 @@ dashboard = { workspace = true } permissions = { workspace = true } background-tasks = { workspace = true } file-storage = { workspace = true } +settings = { workspace = true } diff --git a/modules/settings/package.json b/modules/settings/package.json new file mode 100644 index 00000000..cbefe545 --- /dev/null +++ b/modules/settings/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module/settings", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Settings module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*" + }, + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/settings/pyproject.toml b/modules/settings/pyproject.toml new file mode 100644 index 00000000..ebd236f1 --- /dev/null +++ b/modules/settings/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "settings" +version = "0.1.0" +description = "The Settings module" +authors = [] +requires-python = ">=3.12" +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", +] + +[project.entry-points.simple_module] +settings = "settings.module:SettingsModule" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } diff --git a/modules/settings/settings/__init__.py b/modules/settings/settings/__init__.py new file mode 100644 index 00000000..b5a07b98 --- /dev/null +++ b/modules/settings/settings/__init__.py @@ -0,0 +1 @@ +"""Settings module.""" diff --git a/modules/settings/settings/constants.py b/modules/settings/settings/constants.py new file mode 100644 index 00000000..b3034954 --- /dev/null +++ b/modules/settings/settings/constants.py @@ -0,0 +1,112 @@ +"""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 + +# ── Module identity ────────────────────────────────────────────────── +MODULE_NAME: Final = "Settings" +MODULE_PACKAGE: Final = "settings" +ENV_PREFIX: Final = "SM_SETTINGS_" +LOCALE_NAMESPACE: Final = MODULE_PACKAGE + +# ── Scopes ─────────────────────────────────────────────────────────── +# Precedence high → low when resolving a key: USER > TENANT > SYSTEM. +SCOPE_SYSTEM: Final = "system" +SCOPE_TENANT: Final = "tenant" +SCOPE_USER: Final = "user" +ALL_SCOPES: Final = (SCOPE_SYSTEM, SCOPE_TENANT, SCOPE_USER) +# scope_id is empty for SYSTEM; empty string (not NULL) so composite unique +# works uniformly on SQLite and PostgreSQL (NULL breaks uniqueness on PG). +SYSTEM_SCOPE_ID: Final = "" + +# ── Value types ────────────────────────────────────────────────────── +# Values are always stored as strings; ``value_type`` tells consumers how +# to interpret the bytes and lets the UI pick the right input control. +VALUE_TYPE_STRING: Final = "string" +VALUE_TYPE_BOOL: Final = "bool" +VALUE_TYPE_INT: Final = "int" +VALUE_TYPE_FLOAT: Final = "float" +VALUE_TYPE_JSON: Final = "json" +ALL_VALUE_TYPES: Final = ( + VALUE_TYPE_STRING, + VALUE_TYPE_BOOL, + VALUE_TYPE_INT, + VALUE_TYPE_FLOAT, + VALUE_TYPE_JSON, +) + +# ── Routing ────────────────────────────────────────────────────────── +API_PREFIX: Final = "/api/settings" +VIEW_PREFIX: Final = "/settings" +VIEW_CREATE_PATH: Final = "/create" +VIEW_EDIT_PATH: Final = "/{setting_id}/edit" +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}" + +# ── Menu ───────────────────────────────────────────────────────────── +MENU_LABEL: Final = MODULE_NAME +MENU_URL: Final = VIEW_PREFIX +MENU_ICON: Final = "settings" +MENU_ORDER: Final = 30 + +# ── 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" +ALL_PERMISSIONS: Final = (PERM_VIEW, PERM_CREATE, PERM_EDIT, PERM_DELETE) + +# ── Database ───────────────────────────────────────────────────────── +DB_SCHEMA: Final = MODULE_PACKAGE +TABLE_SETTING: Final = "settings_setting" +UQ_SCOPE_KEY: Final = "uq_settings_setting_scope_scope_id_key" + +# ── Field limits ───────────────────────────────────────────────────── +KEY_MAX_LENGTH: Final = 200 +VALUE_MAX_LENGTH: Final = 4000 +DESCRIPTION_MAX_LENGTH: Final = 2000 +SCOPE_MAX_LENGTH: Final = 10 +SCOPE_ID_MAX_LENGTH: Final = 255 +VALUE_TYPE_MAX_LENGTH: Final = 10 + +# ── 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" + +# ── Inertia prop keys ──────────────────────────────────────────────── +PROP_SETTINGS: Final = "settings" +PROP_SETTING: Final = "setting" +PROP_ERROR: Final = "error" + +# ── User-facing error messages ─────────────────────────────────────── +ERR_SETTING_NOT_FOUND: Final = "Setting not found" +ERR_KEY_ALREADY_EXISTS: Final = "Setting 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" + +# ── HTTP ───────────────────────────────────────────────────────────── +STATUS_CREATED: Final = 201 +STATUS_NO_CONTENT: Final = 204 +STATUS_NOT_FOUND: Final = 404 +STATUS_CONFLICT: Final = 409 + +# ── 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" diff --git a/modules/settings/settings/contracts/__init__.py b/modules/settings/settings/contracts/__init__.py new file mode 100644 index 00000000..4e64a43e --- /dev/null +++ b/modules/settings/settings/contracts/__init__.py @@ -0,0 +1,26 @@ +"""Settings contracts — public interface for other modules.""" + +from settings.contracts.accessor import SettingsAccessor +from settings.contracts.registry import SettingDefinition, SettingsRegistry +from settings.contracts.schemas import ( + SettingCreate, + SettingOut, + SettingScope, + SettingUpdate, + SettingUpsert, + SettingValueType, +) +from settings.contracts.service import ISettingService + +__all__ = [ + "ISettingService", + "SettingCreate", + "SettingDefinition", + "SettingOut", + "SettingScope", + "SettingUpdate", + "SettingUpsert", + "SettingValueType", + "SettingsAccessor", + "SettingsRegistry", +] diff --git a/modules/settings/settings/contracts/accessor.py b/modules/settings/settings/contracts/accessor.py new file mode 100644 index 00000000..2c933b2c --- /dev/null +++ b/modules/settings/settings/contracts/accessor.py @@ -0,0 +1,234 @@ +"""High-level read/write facade over ``SettingService`` for consumer modules. + +Consumers depend on this instead of ``SettingService`` directly so they get: +- automatic USER > TENANT > SYSTEM resolution bound to the request context, +- typed getters (`get_bool`, `get_int`, `get_float`, `get_json`) that cast + the stored string representation, +- fallback to the registered default in ``SettingsRegistry`` when a key is + unset at every scope. + +Example in another module's endpoint: + + from settings.contracts import SettingsDep + + @router.get("/whatever") + async def handler(settings: SettingsDep): + if await settings.get_bool("orders.bulk_import", default=False): + ... +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any, Final + +from settings.constants import SYSTEM_SCOPE_ID +from settings.contracts.registry import SettingsRegistry +from settings.contracts.schemas import ( + BOOL_LITERALS_FALSE, + BOOL_LITERALS_TRUE, + SettingOut, + SettingScope, + SettingUpsert, + SettingValueType, +) +from settings.contracts.service import ISettingService + + +class _Unset: + """Typed sentinel for ``bind``'s "no override" marker.""" + + +_UNSET: Final = _Unset() + + +def _cast_bool(raw: str, default: Any) -> Any: + lowered = raw.strip().lower() + if lowered in BOOL_LITERALS_TRUE: + return True + if lowered in BOOL_LITERALS_FALSE: + return False + return default + + +def _cast_int(raw: str, default: Any) -> Any: + try: + return int(raw) + except (TypeError, ValueError): + return default + + +def _cast_float(raw: str, default: Any) -> Any: + try: + return float(raw) + except (TypeError, ValueError): + return default + + +def _cast_json(raw: str, default: Any) -> Any: + try: + return json.loads(raw) + except (TypeError, ValueError): + return default + + +_CASTERS: Final[dict[SettingValueType, Callable[[str, Any], Any]]] = { + SettingValueType.BOOL: _cast_bool, + SettingValueType.INT: _cast_int, + SettingValueType.FLOAT: _cast_float, + SettingValueType.JSON: _cast_json, +} + + +class SettingsAccessor: + """Request-scoped facade over ``ISettingService``. + + Bound to an optional ``user_id`` + ``tenant_id`` so ``get`` and its + typed variants resolve via USER > TENANT > SYSTEM automatically. + ``SettingService`` (the implementation) is still reachable for admin + flows that need unbound operations. + """ + + __slots__ = ("_registry", "_svc", "_tenant_id", "_user_id") + + def __init__( + self, + service: ISettingService, + registry: SettingsRegistry | None = None, + *, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> None: + self._svc = service + self._registry = registry + self._user_id = user_id + self._tenant_id = tenant_id + + @property + def service(self) -> ISettingService: + return self._svc + + @property + def registry(self) -> SettingsRegistry | None: + return self._registry + + def bind( + self, + *, + user_id: str | None | _Unset = _UNSET, + tenant_id: str | None | _Unset = _UNSET, + ) -> SettingsAccessor: + """Return a new accessor with the given user/tenant overrides. + + Pass ``None`` explicitly to clear that side of the context; + omit the argument to preserve the current value. ``bind()`` with + no arguments returns a shallow copy bound to the same context. + """ + return SettingsAccessor( + self._svc, + self._registry, + user_id=self._user_id if isinstance(user_id, _Unset) else user_id, + tenant_id=self._tenant_id if isinstance(tenant_id, _Unset) else tenant_id, + ) + + # ── Typed reads ───────────────────────────────────────────────── + + async def get(self, key: str, default: str | None = None) -> str | None: + """Resolve a key as a string, walking USER > TENANT > SYSTEM. + + Falls back to the explicit ``default`` argument, then the + registered default in ``SettingsRegistry`` if present. + """ + value = await self._svc.get_resolved_value( + key, user_id=self._user_id, tenant_id=self._tenant_id + ) + if value is not None: + return value + if default is not None: + return default + if self._registry is not None: + definition = self._registry.get(key) + if definition is not None: + return definition.default + return None + + async def get_str(self, key: str, default: str = "") -> str: + value = await self.get(key) + return value if value is not None else default + + async def get_bool(self, key: str, default: bool = False) -> bool: + raw = await self.get(key) + return default if raw is None else _cast_bool(raw, default) + + async def get_int(self, key: str, default: int = 0) -> int: + raw = await self.get(key) + return default if raw is None else _cast_int(raw, default) + + async def get_float(self, key: str, default: float = 0.0) -> float: + raw = await self.get(key) + return default if raw is None else _cast_float(raw, default) + + async def get_json(self, key: str, default: Any = None) -> Any: + raw = await self.get(key) + return default if raw is None else _cast_json(raw, default) + + async def get_typed(self, key: str, default: Any = None) -> Any: + """Resolve a key and cast based on the stored ``value_type``. + + Dispatches by the declared type of the row that wins resolution; + falls back to the raw string for ``STRING`` or when no row exists. + Useful for generic admin views that don't know each key's type at + compile time. + """ + out = await self._svc.resolve(key, user_id=self._user_id, tenant_id=self._tenant_id) + if out is None: + return default + caster = _CASTERS.get(out.value_type) + return out.value if caster is None else caster(out.value, default) + + # ── Writes ────────────────────────────────────────────────────── + + async def set_system( + self, + key: str, + value: str, + value_type: SettingValueType | None = None, + description: str | None = None, + ) -> SettingOut: + return await self._svc.upsert_scoped( + SettingScope.SYSTEM, + SYSTEM_SCOPE_ID, + key, + SettingUpsert(value=value, value_type=value_type, description=description), + ) + + async def set_tenant( + self, + tenant_id: str, + key: str, + value: str, + value_type: SettingValueType | None = None, + description: str | None = None, + ) -> SettingOut: + return await self._svc.upsert_scoped( + SettingScope.TENANT, + tenant_id, + key, + SettingUpsert(value=value, value_type=value_type, description=description), + ) + + async def set_user( + self, + user_id: str, + key: str, + value: str, + value_type: SettingValueType | None = None, + description: str | None = None, + ) -> SettingOut: + return await self._svc.upsert_scoped( + SettingScope.USER, + user_id, + key, + SettingUpsert(value=value, value_type=value_type, description=description), + ) diff --git a/modules/settings/settings/contracts/registry.py b/modules/settings/settings/contracts/registry.py new file mode 100644 index 00000000..237fc9a8 --- /dev/null +++ b/modules/settings/settings/contracts/registry.py @@ -0,0 +1,66 @@ +"""Registry of declared setting keys — lets modules advertise the keys they +read so admins can see every knob (and its default) in one place. + +Usage from a consumer module's ``on_startup`` hook: + + def on_startup(self, app): + app.state.settings.registry.add( + SettingDefinition( + key="orders.checkout.require_terms", + default="true", + description="Show the terms-and-conditions checkbox on checkout.", + ) + ) + +The registry doesn't write anything to the database — it only records +intent. ``SettingsAccessor.get`` falls back to the registered default when +no row exists at any scope. ``get_bool`` / ``get_int`` / ``get_json`` cast +the stored string representation on the way out. + +API mirrors the other framework registries (MenuRegistry / PermissionRegistry +/ FeatureFlagRegistry): ``add(definition)`` + ``all_definitions``. Unlike +FeatureFlagRegistry, duplicate keys raise — settings carry defaults and a +second registration almost always means two owners contended for the same key. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from settings.constants import ERR_KEY_ALREADY_EXISTS +from settings.contracts.schemas import SettingScope, SettingValueType + + +@dataclass(frozen=True, slots=True) +class SettingDefinition: + """Declared metadata for a setting key.""" + + key: str + default: str = "" + description: str = "" + scope: SettingScope = SettingScope.SYSTEM + value_type: SettingValueType = SettingValueType.STRING + + +@dataclass(slots=True) +class SettingsRegistry: + """In-memory registry of declared setting keys. Populated at module boot + so admins (and other modules) can discover every knob the app exposes. + """ + + _defs: dict[str, SettingDefinition] = field(default_factory=dict) + + def add(self, definition: SettingDefinition) -> None: + if definition.key in self._defs: + raise ValueError(f"{ERR_KEY_ALREADY_EXISTS}: {definition.key!r}") + self._defs[definition.key] = definition + + def get(self, key: str) -> SettingDefinition | None: + return self._defs.get(key) + + @property + def all_definitions(self) -> list[SettingDefinition]: + return list(self._defs.values()) + + def __contains__(self, key: str) -> bool: + return key in self._defs diff --git a/modules/settings/settings/contracts/schemas.py b/modules/settings/settings/contracts/schemas.py new file mode 100644 index 00000000..307319c9 --- /dev/null +++ b/modules/settings/settings/contracts/schemas.py @@ -0,0 +1,154 @@ +"""SQLModel DTOs + SettingScope / SettingValueType enums.""" + +from __future__ import annotations + +import json +from datetime import datetime +from enum import StrEnum + +from pydantic import ConfigDict, model_validator +from sqlmodel import Field, SQLModel + +from settings.constants import ( + DESCRIPTION_MAX_LENGTH, + ERR_SCOPED_REQUIRES_ID, + ERR_SYSTEM_SCOPE_NO_ID, + ERR_VALUE_MISMATCH, + KEY_MAX_LENGTH, + SCOPE_ID_MAX_LENGTH, + SCOPE_SYSTEM, + SCOPE_TENANT, + SCOPE_USER, + SYSTEM_SCOPE_ID, + VALUE_MAX_LENGTH, + VALUE_TYPE_BOOL, + VALUE_TYPE_FLOAT, + VALUE_TYPE_INT, + VALUE_TYPE_JSON, + VALUE_TYPE_STRING, +) + +_BOOL_LITERALS = frozenset( + {"true", "false", "1", "0", "t", "f", "yes", "no", "y", "n", "on", "off"} +) +BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) +BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"}) + + +class SettingScope(StrEnum): + """Override level for a setting entry.""" + + SYSTEM = SCOPE_SYSTEM + TENANT = SCOPE_TENANT + USER = SCOPE_USER + + +class SettingValueType(StrEnum): + """How the stored ``value`` string should be interpreted.""" + + STRING = VALUE_TYPE_STRING + BOOL = VALUE_TYPE_BOOL + INT = VALUE_TYPE_INT + FLOAT = VALUE_TYPE_FLOAT + JSON = VALUE_TYPE_JSON + + +def _validate_scope_id(scope: SettingScope, scope_id: str) -> None: + if scope is SettingScope.SYSTEM and scope_id: + raise ValueError(ERR_SYSTEM_SCOPE_NO_ID) + if scope is not SettingScope.SYSTEM and not scope_id: + raise ValueError(ERR_SCOPED_REQUIRES_ID) + + +def _validate_value_matches_type(value: str, value_type: SettingValueType) -> None: + """Ensure ``value`` parses as the declared type. Empty values are always ok.""" + if value == "": + return + if value_type is SettingValueType.STRING: + return + if value_type is SettingValueType.BOOL: + if value.strip().lower() not in _BOOL_LITERALS: + raise ValueError(ERR_VALUE_MISMATCH) + return + if value_type is SettingValueType.INT: + try: + int(value) + except ValueError as exc: + raise ValueError(ERR_VALUE_MISMATCH) from exc + return + if value_type is SettingValueType.FLOAT: + try: + float(value) + except ValueError as exc: + raise ValueError(ERR_VALUE_MISMATCH) from exc + return + if value_type is SettingValueType.JSON: + try: + json.loads(value) + except (TypeError, ValueError) as exc: + raise ValueError(ERR_VALUE_MISMATCH) from exc + + +class SettingOut(SQLModel): + """A setting returned by the API.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + scope: SettingScope + scope_id: str + key: str + value: str + value_type: SettingValueType = SettingValueType.STRING + description: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class SettingCreate(SQLModel): + """Payload to create a new setting at an explicit scope.""" + + scope: SettingScope = SettingScope.SYSTEM + scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH) + key: str = Field(min_length=1, max_length=KEY_MAX_LENGTH) + value: str = Field(max_length=VALUE_MAX_LENGTH) + value_type: SettingValueType = SettingValueType.STRING + description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH) + + @model_validator(mode="after") + def _check(self) -> SettingCreate: + _validate_scope_id(self.scope, self.scope_id) + _validate_value_matches_type(self.value, self.value_type) + return self + + +class SettingUpdate(SQLModel): + """Payload to update an existing setting by id. Scope cannot change.""" + + value: str | None = Field(default=None, max_length=VALUE_MAX_LENGTH) + value_type: SettingValueType | None = None + description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH) + + @model_validator(mode="after") + def _check(self) -> SettingUpdate: + if self.value is not None and self.value_type is not None: + _validate_value_matches_type(self.value, self.value_type) + return self + + +class SettingUpsert(SQLModel): + """Payload for upsert operations (value + optional type + description). + + ``value_type`` is optional: when absent on an update it preserves the + existing row's type, and on a create it defaults to ``STRING``. + """ + + value: str = Field(max_length=VALUE_MAX_LENGTH) + value_type: SettingValueType | None = None + description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH) + + @model_validator(mode="after") + def _check(self) -> SettingUpsert: + if self.value_type is not None: + _validate_value_matches_type(self.value, self.value_type) + return self diff --git a/modules/settings/settings/contracts/service.py b/modules/settings/settings/contracts/service.py new file mode 100644 index 00000000..f9dcbf9c --- /dev/null +++ b/modules/settings/settings/contracts/service.py @@ -0,0 +1,50 @@ +"""Setting service protocol — the public contract other modules depend on.""" + +from __future__ import annotations + +from typing import Protocol + +from settings.contracts.schemas import ( + SettingCreate, + SettingOut, + SettingScope, + SettingUpdate, + SettingUpsert, +) + + +class ISettingService(Protocol): + """Interface for scoped key/value settings. + + Resolution precedence (high → low): USER > TENANT > SYSTEM. + """ + + async def list_all(self) -> list[SettingOut]: ... + async def list_by_scope(self, scope: SettingScope, scope_id: str = "") -> list[SettingOut]: ... + + async def get_by_id(self, setting_id: int) -> SettingOut | None: ... + async def get_scoped( + self, scope: SettingScope, scope_id: str, key: str + ) -> SettingOut | None: ... + async def resolve( + self, + key: str, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> SettingOut | 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: ... + + async def create(self, data: SettingCreate) -> SettingOut: ... + async def update(self, setting_id: int, data: SettingUpdate) -> SettingOut | None: ... + async def upsert_scoped( + self, scope: SettingScope, scope_id: str, key: str, data: SettingUpsert + ) -> SettingOut: ... + + async def delete(self, setting_id: int) -> bool: ... + async def delete_scoped(self, scope: SettingScope, scope_id: str, key: str) -> bool: ... diff --git a/modules/settings/settings/deps.py b/modules/settings/settings/deps.py new file mode 100644 index 00000000..7ea13394 --- /dev/null +++ b/modules/settings/settings/deps.py @@ -0,0 +1,56 @@ +"""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_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 + + +async def get_setting_service( + db: AsyncSession = Depends(get_db), +) -> SettingService: + return SettingService(db) + + +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/endpoints/__init__.py b/modules/settings/settings/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/settings/settings/endpoints/api.py b/modules/settings/settings/endpoints/api.py new file mode 100644 index 00000000..821d32f3 --- /dev/null +++ b/modules/settings/settings/endpoints/api.py @@ -0,0 +1,202 @@ +"""REST API endpoints for the Settings module.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from settings.constants import ( + API_BY_ID_PATH, + API_RESOLVE_PATH, + API_SYSTEM_PATH, + API_TENANT_PATH, + API_USER_PATH, + ERR_SETTING_NOT_FOUND, + QP_SCOPE, + QP_SCOPE_ID, + QP_TENANT_ID, + QP_USER_ID, + STATUS_CREATED, + STATUS_NO_CONTENT, + STATUS_NOT_FOUND, + SYSTEM_SCOPE_ID, +) +from settings.contracts.schemas import ( + SettingCreate, + SettingOut, + SettingScope, + SettingUpdate, + SettingUpsert, +) +from settings.deps import get_setting_service +from settings.service import SettingService + +router = APIRouter() + + +def _not_found() -> HTTPException: + return HTTPException(status_code=STATUS_NOT_FOUND, detail=ERR_SETTING_NOT_FOUND) + + +# ── List / filter ─────────────────────────────────────────────────── + + +@router.get("/", response_model=list[SettingOut]) +async def list_settings( + scope: SettingScope | None = Query(default=None, alias=QP_SCOPE), + scope_id: str = Query(default=SYSTEM_SCOPE_ID, alias=QP_SCOPE_ID), + service: SettingService = Depends(get_setting_service), +) -> list[SettingOut]: + if scope is None: + return await service.list_all() + return await service.list_by_scope(scope, scope_id) + + +# ── Resolution (USER > TENANT > SYSTEM) ───────────────────────────── + + +@router.get(API_RESOLVE_PATH, response_model=SettingOut) +async def resolve_setting( + key: str, + user_id: str | None = Query(default=None, alias=QP_USER_ID), + tenant_id: str | None = Query(default=None, alias=QP_TENANT_ID), + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + result = await service.resolve(key, user_id=user_id, tenant_id=tenant_id) + if result is None: + raise _not_found() + return result + + +# ── Scoped (system / tenant / user) ───────────────────────────────── + + +@router.get(API_SYSTEM_PATH, response_model=SettingOut) +async def get_system_setting( + key: str, service: SettingService = Depends(get_setting_service) +) -> SettingOut: + result = await service.get_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key) + if result is None: + raise _not_found() + return result + + +@router.put(API_SYSTEM_PATH, response_model=SettingOut) +async def upsert_system_setting( + key: str, + data: SettingUpsert, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + return await service.upsert_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key, data) + + +@router.delete(API_SYSTEM_PATH, status_code=STATUS_NO_CONTENT) +async def delete_system_setting( + key: str, service: SettingService = Depends(get_setting_service) +) -> None: + if not await service.delete_scoped(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key): + raise _not_found() + + +@router.get(API_TENANT_PATH, response_model=SettingOut) +async def get_tenant_setting( + scope_id: str, + key: str, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + result = await service.get_scoped(SettingScope.TENANT, scope_id, key) + if result is None: + raise _not_found() + return result + + +@router.put(API_TENANT_PATH, response_model=SettingOut) +async def upsert_tenant_setting( + scope_id: str, + key: str, + data: SettingUpsert, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + return await service.upsert_scoped(SettingScope.TENANT, scope_id, key, data) + + +@router.delete(API_TENANT_PATH, status_code=STATUS_NO_CONTENT) +async def delete_tenant_setting( + scope_id: str, + key: str, + service: SettingService = Depends(get_setting_service), +) -> None: + if not await service.delete_scoped(SettingScope.TENANT, scope_id, key): + raise _not_found() + + +@router.get(API_USER_PATH, response_model=SettingOut) +async def get_user_setting( + scope_id: str, + key: str, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + result = await service.get_scoped(SettingScope.USER, scope_id, key) + if result is None: + raise _not_found() + return result + + +@router.put(API_USER_PATH, response_model=SettingOut) +async def upsert_user_setting( + scope_id: str, + key: str, + data: SettingUpsert, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + return await service.upsert_scoped(SettingScope.USER, scope_id, key, data) + + +@router.delete(API_USER_PATH, status_code=STATUS_NO_CONTENT) +async def delete_user_setting( + scope_id: str, + key: str, + service: SettingService = Depends(get_setting_service), +) -> None: + if not await service.delete_scoped(SettingScope.USER, scope_id, key): + raise _not_found() + + +# ── Id-based CRUD (admin tooling) ─────────────────────────────────── + + +@router.post("/", response_model=SettingOut, status_code=STATUS_CREATED) +async def create_setting( + data: SettingCreate, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + return await service.create(data) + + +@router.get(API_BY_ID_PATH, response_model=SettingOut) +async def get_setting( + setting_id: int, service: SettingService = Depends(get_setting_service) +) -> SettingOut: + result = await service.get_by_id(setting_id) + if result is None: + raise _not_found() + return result + + +@router.put(API_BY_ID_PATH, response_model=SettingOut) +async def update_setting( + setting_id: int, + data: SettingUpdate, + service: SettingService = Depends(get_setting_service), +) -> SettingOut: + result = await service.update(setting_id, data) + if result is None: + raise _not_found() + return result + + +@router.delete(API_BY_ID_PATH, status_code=STATUS_NO_CONTENT) +async def delete_setting( + setting_id: int, service: SettingService = Depends(get_setting_service) +) -> None: + if not await service.delete(setting_id): + raise _not_found() diff --git a/modules/settings/settings/endpoints/views.py b/modules/settings/settings/endpoints/views.py new file mode 100644 index 00000000..f5ea5da8 --- /dev/null +++ b/modules/settings/settings/endpoints/views.py @@ -0,0 +1,57 @@ +"""Inertia view endpoints for the Settings module. + +Page component identifiers are inlined as string literals here (instead of +imported from ``settings.constants``) so the ``SM003`` orphan-page doctor +check — which parses calls to ``inertia.render`` via AST literal matching — +can correlate views to their ``pages/*.tsx`` files. The literals must match +``PAGE_BROWSE`` / ``PAGE_CREATE`` / ``PAGE_EDIT`` in ``constants.py``, and +a test in ``test_settings_module.py`` enforces that invariant. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from inertia import InertiaResponse +from simple_module_hosting.inertia_deps import InertiaDep + +from settings.constants import ( + ERR_SETTING_NOT_FOUND, + PROP_ERROR, + PROP_SETTING, + PROP_SETTINGS, + VIEW_CREATE_PATH, + VIEW_EDIT_PATH, +) +from settings.deps import get_setting_service +from settings.service import SettingService + +router = APIRouter() + + +@router.get("/", response_model=None) +async def browse( + inertia: InertiaDep, + service: SettingService = Depends(get_setting_service), +) -> InertiaResponse: + items = await service.list_all() + return await inertia.render( + "Settings/Browse", + {PROP_SETTINGS: [item.model_dump(mode="json") for item in items]}, + ) + + +@router.get(VIEW_CREATE_PATH, response_model=None) +async def create_view(inertia: InertiaDep) -> InertiaResponse: + return await inertia.render("Settings/Create") + + +@router.get(VIEW_EDIT_PATH, response_model=None) +async def edit_view( + setting_id: int, + inertia: InertiaDep, + service: SettingService = Depends(get_setting_service), +) -> InertiaResponse: + item = await service.get_by_id(setting_id) + if item is None: + return await inertia.render("Settings/Browse", {PROP_ERROR: ERR_SETTING_NOT_FOUND}) + return await inertia.render("Settings/Edit", {PROP_SETTING: item.model_dump(mode="json")}) diff --git a/modules/settings/settings/locales/en.json b/modules/settings/settings/locales/en.json new file mode 100644 index 00000000..cf73fd0b --- /dev/null +++ b/modules/settings/settings/locales/en.json @@ -0,0 +1,50 @@ +{ + "browse": { + "title": "Settings", + "new_button": "New Setting", + "empty_title": "No settings yet", + "empty_description": "Get started by creating your first setting.", + "edit_link": "Edit" + }, + "table": { + "scope": "Scope", + "scope_id": "Scope ID", + "key": "Key", + "value": "Value", + "value_type": "Type", + "description": "Description", + "actions": "Actions" + }, + "scopes": { + "system": "System", + "tenant": "Tenant", + "user": "User" + }, + "value_types": { + "string": "String", + "bool": "Boolean", + "int": "Integer", + "float": "Float", + "json": "JSON" + }, + "form": { + "scope_label": "Scope", + "scope_id_label": "Scope ID", + "scope_id_placeholder": "Tenant or user id (empty for system)", + "key_label": "Key", + "key_placeholder": "e.g. feature.enabled", + "value_type_label": "Type", + "value_label": "Value", + "value_placeholder": "Enter a value", + "description_label": "Description", + "description_placeholder": "Optional description" + }, + "create": { + "title": "New Setting", + "submit_button": "Create" + }, + "edit": { + "title": "Edit Setting", + "submit_button": "Save" + } +} diff --git a/modules/settings/settings/models.py b/modules/settings/settings/models.py new file mode 100644 index 00000000..7a515d82 --- /dev/null +++ b/modules/settings/settings/models.py @@ -0,0 +1,50 @@ +"""SQLModel tables for the Settings module.""" + +from __future__ import annotations + +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import UniqueConstraint +from sqlmodel import Field + +from settings.constants import ( + DESCRIPTION_MAX_LENGTH, + KEY_MAX_LENGTH, + MODULE_PACKAGE, + SCOPE_ID_MAX_LENGTH, + SCOPE_MAX_LENGTH, + SCOPE_SYSTEM, + SYSTEM_SCOPE_ID, + TABLE_SETTING, + UQ_SCOPE_KEY, + VALUE_MAX_LENGTH, + VALUE_TYPE_MAX_LENGTH, + VALUE_TYPE_STRING, +) + +Base = create_module_base(MODULE_PACKAGE) + + +class Setting(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """A typed key/value configuration entry scoped to system, tenant, or user. + + Resolution precedence when a consumer asks for a key: USER > TENANT > + SYSTEM. Uniqueness is enforced across the (scope, scope_id, key) tuple + so the same key can live at different scopes without collision. + + ``value`` is always stored as a string. ``value_type`` advertises how + the bytes should be interpreted ("string", "bool", "int", "float", + "json") so UIs pick the right input control and pydantic can reject + inputs that don't parse. + """ + + __tablename__ = TABLE_SETTING + __table_args__ = (UniqueConstraint("scope", "scope_id", "key", name=UQ_SCOPE_KEY),) + + id: int | None = Field(default=None, primary_key=True) + scope: str = Field(default=SCOPE_SYSTEM, max_length=SCOPE_MAX_LENGTH, index=True) + scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH, index=True) + key: str = Field(max_length=KEY_MAX_LENGTH, index=True) + value: str = Field(max_length=VALUE_MAX_LENGTH) + value_type: str = Field(default=VALUE_TYPE_STRING, max_length=VALUE_TYPE_MAX_LENGTH) + description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH) diff --git a/modules/settings/settings/module.py b/modules/settings/settings/module.py new file mode 100644 index 00000000..dbff6fe6 --- /dev/null +++ b/modules/settings/settings/module.py @@ -0,0 +1,64 @@ +"""Settings module definition.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +from fastapi import APIRouter, FastAPI +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from settings.constants import ( + ALL_PERMISSIONS, + API_PREFIX, + LOCALE_NAMESPACE, + MENU_ICON, + MENU_LABEL, + MENU_ORDER, + MENU_URL, + MODULE_NAME, + MODULE_PACKAGE, + PERM_GROUP, + VIEW_PREFIX, +) + + +class SettingsModule(ModuleBase): + meta = ModuleMeta( + name=MODULE_NAME, + route_prefix=API_PREFIX, + view_prefix=VIEW_PREFIX, + ) + + def register_settings(self, app: FastAPI) -> None: + from settings.services import SettingsServices + from settings.settings import SettingsSettings + + setattr(app.state, MODULE_PACKAGE, SettingsServices(settings=SettingsSettings())) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from settings.endpoints.api import router as api + from settings.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label=MENU_LABEL, + url=MENU_URL, + icon=MENU_ICON, + order=MENU_ORDER, + section=MenuSection.SIDEBAR, + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS)) + + def locale_dirs(self) -> dict[str, Path]: + base = Path(str(importlib.resources.files(__package__) / "locales")) + return {LOCALE_NAMESPACE: base} diff --git a/modules/settings/settings/pages/Browse.tsx b/modules/settings/settings/pages/Browse.tsx new file mode 100644 index 00000000..7b7d06d6 --- /dev/null +++ b/modules/settings/settings/pages/Browse.tsx @@ -0,0 +1,72 @@ +import { keys, useT } from '@simple-module/i18n'; +import type { ValueType } from './components/ValueInput'; +import { ROUTES } from './routes'; + +type Scope = 'system' | 'tenant' | 'user'; + +type Setting = { + id: number; + scope: Scope; + scope_id: string; + key: string; + value: string; + value_type: ValueType; + description: string | null; +}; + +type Props = { settings: Setting[] }; + +export default function Browse({ settings }: Props) { + const { t } = useT(); + return ( +
+
+

{t(keys.settings.browse.title)}

+ + {t(keys.settings.browse.new_button)} + +
+ {settings.length === 0 ? ( +
+

{t(keys.settings.browse.empty_title)}

+

+ {t(keys.settings.browse.empty_description)} +

+
+ ) : ( + + + + + + + + + + + + + + {settings.map((setting) => ( + + + + + + + + + + ))} + +
{t(keys.settings.table.scope)}{t(keys.settings.table.scope_id)}{t(keys.settings.table.key)}{t(keys.settings.table.value_type)}{t(keys.settings.table.value)}{t(keys.settings.table.description)}{t(keys.settings.table.actions)}
{t(keys.settings.scopes[setting.scope])} + {setting.scope_id || '—'} + {setting.key} + {t(keys.settings.value_types[setting.value_type])} + {setting.value}{setting.description ?? ''} + {t(keys.settings.browse.edit_link)} +
+ )} +
+ ); +} diff --git a/modules/settings/settings/pages/Create.tsx b/modules/settings/settings/pages/Create.tsx new file mode 100644 index 00000000..1197ce12 --- /dev/null +++ b/modules/settings/settings/pages/Create.tsx @@ -0,0 +1,75 @@ +import { keys, useT } from '@simple-module/i18n'; +import { useState } from 'react'; +import ValueInput, { VALUE_TYPES, type ValueType } from './components/ValueInput'; +import { ROUTES } from './routes'; + +const SCOPES = ['system', 'tenant', 'user'] as const; + +export default function Create() { + const { t } = useT(); + const [valueType, setValueType] = useState('string'); + return ( +
+

{t(keys.settings.create.title)}

+
+ + + + +
+ {t(keys.settings.form.value_label)} + +
+