simple-module-settings 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
3
+ import type React from 'react';
4
+ import { useMemo, useState } from 'react';
5
+ import { ModuleForm, type ModuleView } from './components/ModuleForm';
6
+ import { ROUTES } from './routes';
7
+
8
+ type Props = { modules: ModuleView[] };
9
+
10
+ function ModulesEdit({ modules }: Props) {
11
+ const { t } = useT();
12
+ const [selected, setSelected] = useState(modules[0]?.package);
13
+ const [q, setQ] = useState('');
14
+
15
+ const filtered = useMemo(() => {
16
+ if (!q) return modules;
17
+ const query = q.toLowerCase();
18
+ return modules.filter(
19
+ (m) =>
20
+ m.module_name.toLowerCase().includes(query) ||
21
+ m.package.toLowerCase().includes(query) ||
22
+ m.fields.some((f) => f.name.toLowerCase().includes(query)),
23
+ );
24
+ }, [modules, q]);
25
+
26
+ const current = modules.find((m) => m.package === selected);
27
+
28
+ return (
29
+ <div className="flex h-[calc(100vh-64px)]">
30
+ <aside className="w-64 border-r bg-muted/40 p-3 overflow-y-auto">
31
+ <input
32
+ type="text"
33
+ placeholder={t(keys.settings.modules.search_placeholder)}
34
+ value={q}
35
+ onChange={(e) => setQ(e.target.value)}
36
+ className="mb-3 w-full rounded border px-2 py-1 text-sm"
37
+ />
38
+ <nav className="space-y-1">
39
+ {filtered.map((m) => (
40
+ <button
41
+ key={m.package}
42
+ type="button"
43
+ onClick={() => setSelected(m.package)}
44
+ className={`block w-full rounded px-3 py-2 text-left text-sm ${
45
+ m.package === selected ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
46
+ }`}
47
+ >
48
+ <div className="font-medium">{m.module_name}</div>
49
+ <div className="text-xs opacity-70">
50
+ {m.fields.length} {t(keys.settings.modules.field_count_suffix)}
51
+ </div>
52
+ </button>
53
+ ))}
54
+ </nav>
55
+ <div className="mt-4 border-t pt-3 text-xs">
56
+ <a href={ROUTES.browse} className="text-primary hover:underline">
57
+ {t(keys.settings.modules.browse_free_form_link)}
58
+ </a>
59
+ </div>
60
+ </aside>
61
+
62
+ <main className="flex-1 overflow-y-auto p-6">
63
+ {current ? (
64
+ <ModuleForm module={current} />
65
+ ) : (
66
+ <p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
67
+ )}
68
+ </main>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ ModulesEdit.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
74
+ export default ModulesEdit;
@@ -0,0 +1,96 @@
1
+ import { useState } from 'react';
2
+
3
+ export type FieldType = 'bool' | 'int' | 'float' | 'string' | 'json';
4
+
5
+ export type FieldMeta = {
6
+ name: string;
7
+ type: FieldType;
8
+ value: unknown;
9
+ default: unknown;
10
+ description: string;
11
+ is_secret: boolean;
12
+ requires_restart: boolean;
13
+ group: string | null;
14
+ env_var: string;
15
+ };
16
+
17
+ type Props = {
18
+ field: FieldMeta;
19
+ onChange: (name: string, value: unknown) => void;
20
+ value: unknown;
21
+ id?: string;
22
+ };
23
+
24
+ export function FieldInput({ field, onChange, value, id }: Props) {
25
+ const [revealed, setRevealed] = useState(false);
26
+
27
+ if (field.is_secret && !revealed) {
28
+ return (
29
+ <div className="flex items-center gap-2">
30
+ <input
31
+ id={id}
32
+ type="password"
33
+ value="••••••••"
34
+ readOnly
35
+ className="w-full rounded border px-2 py-1 font-mono text-sm"
36
+ />
37
+ <button
38
+ type="button"
39
+ className="text-sm text-primary hover:underline"
40
+ onClick={() => {
41
+ setRevealed(true);
42
+ onChange(field.name, ''); // start blank
43
+ }}
44
+ >
45
+ Set new value
46
+ </button>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ switch (field.type) {
52
+ case 'bool':
53
+ return (
54
+ <input
55
+ id={id}
56
+ type="checkbox"
57
+ checked={!!value}
58
+ onChange={(e) => onChange(field.name, e.target.checked)}
59
+ />
60
+ );
61
+ case 'int':
62
+ case 'float':
63
+ return (
64
+ <input
65
+ id={id}
66
+ type="number"
67
+ step={field.type === 'int' ? '1' : 'any'}
68
+ value={String(value ?? '')}
69
+ onChange={(e) =>
70
+ onChange(field.name, e.target.value === '' ? null : Number(e.target.value))
71
+ }
72
+ className="w-full rounded border px-2 py-1 font-mono text-sm"
73
+ />
74
+ );
75
+ case 'json':
76
+ return (
77
+ <textarea
78
+ id={id}
79
+ rows={3}
80
+ value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
81
+ onChange={(e) => onChange(field.name, e.target.value)}
82
+ className="w-full rounded border px-2 py-1 font-mono text-xs"
83
+ />
84
+ );
85
+ default:
86
+ return (
87
+ <input
88
+ id={id}
89
+ type="text"
90
+ value={String(value ?? '')}
91
+ onChange={(e) => onChange(field.name, e.target.value)}
92
+ className="w-full rounded border px-2 py-1 font-mono text-sm"
93
+ />
94
+ );
95
+ }
96
+ }
@@ -0,0 +1,157 @@
1
+ import { router } from '@inertiajs/react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { FieldInput, type FieldMeta } from './FieldInput';
4
+
5
+ export type ModuleView = {
6
+ module_name: string;
7
+ package: string;
8
+ env_prefix: string;
9
+ class_name: string;
10
+ fields: FieldMeta[];
11
+ };
12
+
13
+ type Props = { module: ModuleView };
14
+
15
+ function notEqual(a: unknown, b: unknown): boolean {
16
+ if (a === b) return false;
17
+ if (typeof a === 'object' || typeof b === 'object') {
18
+ return JSON.stringify(a) !== JSON.stringify(b);
19
+ }
20
+ return true;
21
+ }
22
+
23
+ export function ModuleForm({ module: m }: Props) {
24
+ const initial = useMemo(() => {
25
+ const o: Record<string, unknown> = {};
26
+ for (const f of m.fields) o[f.name] = f.value;
27
+ return o;
28
+ }, [m.fields]);
29
+
30
+ const [values, setValues] = useState<Record<string, unknown>>(initial);
31
+ const [errors, setErrors] = useState<Record<string, string>>({});
32
+ const [busy, setBusy] = useState(false);
33
+
34
+ // Reset the edit buffer whenever the underlying module changes (package
35
+ // switch, or server-reloaded props after a save/reset).
36
+ useEffect(() => {
37
+ setValues(initial);
38
+ setErrors({});
39
+ }, [initial]);
40
+
41
+ const modifiedFields = useMemo(() => {
42
+ const s = new Set<string>();
43
+ for (const name of Object.keys(values)) {
44
+ if (notEqual(values[name], initial[name])) s.add(name);
45
+ }
46
+ return s;
47
+ }, [values, initial]);
48
+
49
+ const defaultByName = useMemo(() => {
50
+ const o: Record<string, unknown> = {};
51
+ for (const f of m.fields) o[f.name] = f.default;
52
+ return o;
53
+ }, [m.fields]);
54
+
55
+ const dirty = modifiedFields.size > 0;
56
+
57
+ const grouped = useMemo(() => {
58
+ const g: Record<string, FieldMeta[]> = {};
59
+ for (const f of m.fields) {
60
+ const key = f.group ?? 'General';
61
+ if (!g[key]) g[key] = [];
62
+ g[key].push(f);
63
+ }
64
+ return g;
65
+ }, [m.fields]);
66
+
67
+ async function onSave() {
68
+ setBusy(true);
69
+ setErrors({});
70
+ const changed: Record<string, unknown> = {};
71
+ for (const name of modifiedFields) changed[name] = values[name];
72
+ const resp = await fetch(`/api/settings/modules/${m.package}`, {
73
+ method: 'PUT',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify(changed),
76
+ });
77
+ if (resp.status === 422) {
78
+ const body = await resp.json();
79
+ const fieldErrs: Record<string, string> = {};
80
+ for (const d of body.detail ?? []) {
81
+ if (d.loc && d.loc.length) fieldErrs[d.loc[d.loc.length - 1]] = d.msg;
82
+ }
83
+ setErrors(fieldErrs);
84
+ } else if (resp.ok) {
85
+ router.reload({ only: ['modules'] });
86
+ }
87
+ setBusy(false);
88
+ }
89
+
90
+ async function onReset(name: string) {
91
+ await fetch(`/api/settings/modules/${m.package}/${name}`, { method: 'DELETE' });
92
+ router.reload({ only: ['modules'] });
93
+ }
94
+
95
+ return (
96
+ <div className="space-y-6">
97
+ <header className="flex items-center justify-between border-b pb-3">
98
+ <div>
99
+ <h2 className="text-xl font-semibold">{m.module_name}</h2>
100
+ <p className="text-xs font-mono text-muted-foreground">{m.package}</p>
101
+ </div>
102
+ <button
103
+ type="button"
104
+ disabled={!dirty || busy}
105
+ onClick={onSave}
106
+ className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
107
+ >
108
+ {busy ? 'Saving…' : 'Save'}
109
+ </button>
110
+ </header>
111
+
112
+ {Object.entries(grouped).map(([group, fields]) => (
113
+ <section key={group} className="space-y-3">
114
+ <h3 className="text-sm font-semibold text-muted-foreground">{group}</h3>
115
+ {fields.map((f) => {
116
+ const isModified = notEqual(values[f.name], defaultByName[f.name]);
117
+ return (
118
+ <div key={f.name} className="grid grid-cols-[1fr_2fr] gap-4 items-start">
119
+ <div>
120
+ <label htmlFor={`field-${m.package}-${f.name}`} className="font-mono text-xs">
121
+ {f.name}
122
+ </label>
123
+ {f.description && (
124
+ <p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
125
+ )}
126
+ {f.requires_restart && isModified && (
127
+ <span className="mt-1 inline-block rounded bg-amber-100 px-1.5 py-0.5 text-[10px] uppercase text-amber-900">
128
+ Requires restart
129
+ </span>
130
+ )}
131
+ </div>
132
+ <div>
133
+ <FieldInput
134
+ id={`field-${m.package}-${f.name}`}
135
+ field={f}
136
+ value={values[f.name]}
137
+ onChange={(name, v) => setValues((prev) => ({ ...prev, [name]: v }))}
138
+ />
139
+ {isModified && (
140
+ <button
141
+ type="button"
142
+ onClick={() => onReset(f.name)}
143
+ className="mt-1 text-xs text-primary hover:underline"
144
+ >
145
+ Reset to default
146
+ </button>
147
+ )}
148
+ {errors[f.name] && <p className="mt-1 text-xs text-red-600">{errors[f.name]}</p>}
149
+ </div>
150
+ </div>
151
+ );
152
+ })}
153
+ </section>
154
+ ))}
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,92 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+
3
+ export type ValueType = 'string' | 'bool' | 'int' | 'float' | 'json';
4
+
5
+ export const VALUE_TYPES: readonly ValueType[] = [
6
+ 'string',
7
+ 'bool',
8
+ 'int',
9
+ 'float',
10
+ 'json',
11
+ ] as const;
12
+
13
+ type Props = {
14
+ valueType: ValueType;
15
+ value: string;
16
+ onValueChange: (value: string) => void;
17
+ required?: boolean;
18
+ };
19
+
20
+ /** Renders the appropriate HTML control for a given `value_type`.
21
+ *
22
+ * Controlled component — emits string values regardless of the declared type.
23
+ * The server re-validates the string against `value_type`.
24
+ */
25
+ export default function ValueInput({ valueType, value, onValueChange, required = false }: Props) {
26
+ const { t } = useT();
27
+
28
+ if (valueType === 'bool') {
29
+ const current = value.toLowerCase();
30
+ return (
31
+ <select
32
+ value={current || 'false'}
33
+ onChange={(e) => onValueChange(e.target.value)}
34
+ className="border rounded w-full p-2"
35
+ >
36
+ <option value="true">true</option>
37
+ <option value="false">false</option>
38
+ </select>
39
+ );
40
+ }
41
+
42
+ if (valueType === 'int') {
43
+ return (
44
+ <input
45
+ type="number"
46
+ step="1"
47
+ value={value}
48
+ onChange={(e) => onValueChange(e.target.value)}
49
+ required={required}
50
+ className="border rounded w-full p-2 font-mono"
51
+ />
52
+ );
53
+ }
54
+
55
+ if (valueType === 'float') {
56
+ return (
57
+ <input
58
+ type="number"
59
+ step="any"
60
+ value={value}
61
+ onChange={(e) => onValueChange(e.target.value)}
62
+ required={required}
63
+ className="border rounded w-full p-2 font-mono"
64
+ />
65
+ );
66
+ }
67
+
68
+ if (valueType === 'json') {
69
+ return (
70
+ <textarea
71
+ value={value}
72
+ onChange={(e) => onValueChange(e.target.value)}
73
+ required={required}
74
+ rows={6}
75
+ placeholder='{"key": "value"}'
76
+ className="border rounded w-full p-2 font-mono text-sm"
77
+ />
78
+ );
79
+ }
80
+
81
+ // string (default)
82
+ return (
83
+ <input
84
+ type="text"
85
+ value={value}
86
+ onChange={(e) => onValueChange(e.target.value)}
87
+ required={required}
88
+ placeholder={t(keys.settings.form.value_placeholder)}
89
+ className="border rounded w-full p-2"
90
+ />
91
+ );
92
+ }
@@ -0,0 +1,7 @@
1
+ export const ROUTES = {
2
+ browse: '/settings',
3
+ modules: '/settings/modules',
4
+ create: '/settings/create',
5
+ edit: (id: number) => `/settings/${id}/edit`,
6
+ byId: (id: number) => `/settings/${id}`,
7
+ } as const;
settings/py.typed ADDED
File without changes
@@ -0,0 +1,34 @@
1
+ """Helper modules call from ``register_settings`` to install their BaseSettings.
2
+
3
+ Two things happen:
4
+ 1. A fresh ``BaseSettings`` (pydantic defaults only) is constructed.
5
+ 2. The class is recorded in ``app.state.settings.module_registry`` so the
6
+ hosting lifespan can hydrate it from the DB before module ``on_startup``
7
+ hooks run.
8
+
9
+ The module's services dataclass is built from the default settings object and
10
+ attached at ``app.state.<package>``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from fastapi import FastAPI
19
+ from pydantic_settings import BaseSettings
20
+
21
+ from settings.constants import MODULE_PACKAGE
22
+
23
+
24
+ def register_module_settings(
25
+ app: FastAPI,
26
+ package: str,
27
+ settings_cls: type[BaseSettings],
28
+ services_factory: Callable[[BaseSettings], Any],
29
+ ) -> None:
30
+ """Register a module's BaseSettings class and mount its services on app.state."""
31
+ registry = getattr(app.state, MODULE_PACKAGE).module_registry
32
+ registry.register(package, settings_cls)
33
+ defaults = settings_cls()
34
+ setattr(app.state, package, services_factory(defaults))
settings/reload.py ADDED
@@ -0,0 +1,58 @@
1
+ """Apply field changes to a module's settings, validate, persist, hot-swap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+ from pydantic_settings import BaseSettings
10
+ from simple_module_core.events import EventBus
11
+
12
+ from settings.constants import MODULE_PACKAGE
13
+ from settings.contracts.events import SettingsReloaded
14
+ from settings.hydrate import value_type_for_field
15
+ from settings.store import SettingsStore
16
+
17
+
18
+ def _encode(value: Any, value_type: str) -> str:
19
+ if value_type == "json":
20
+ return json.dumps(value)
21
+ return str(value)
22
+
23
+
24
+ async def apply_changes_and_reload(
25
+ app: FastAPI,
26
+ bus: EventBus,
27
+ store: SettingsStore,
28
+ *,
29
+ package: str,
30
+ changes: dict[str, Any],
31
+ ) -> BaseSettings:
32
+ """Validate, persist, hot-swap, and publish ``SettingsReloaded``."""
33
+ registry = getattr(app.state, MODULE_PACKAGE).module_registry
34
+ cls = registry.get(package)
35
+ if cls is None:
36
+ raise KeyError(f"Unknown settings package: {package!r}")
37
+
38
+ unknown = set(changes) - set(cls.model_fields)
39
+ if unknown:
40
+ raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}")
41
+
42
+ services = getattr(app.state, package)
43
+ current = services.settings
44
+ diff = {k: v for k, v in changes.items() if getattr(current, k) != v}
45
+ if not diff:
46
+ return current
47
+
48
+ merged = current.model_dump()
49
+ merged.update(diff)
50
+ validated = cls(**merged)
51
+
52
+ for field_name, raw_value in diff.items():
53
+ vtype = value_type_for_field(cls, field_name)
54
+ await store.set_override(package, field_name, _encode(raw_value, vtype), vtype)
55
+
56
+ services.settings = validated
57
+ await bus.publish(SettingsReloaded(package=package, changed=tuple(sorted(diff))))
58
+ return validated
settings/service.py ADDED
@@ -0,0 +1,162 @@
1
+ """Setting service implementation — scoped key/value CRUD + resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from settings.constants import SYSTEM_SCOPE_ID, VALUE_TYPE_STRING
9
+ from settings.contracts.schemas import (
10
+ SettingCreate,
11
+ SettingOut,
12
+ SettingScope,
13
+ SettingUpdate,
14
+ SettingUpsert,
15
+ )
16
+ from settings.models import Setting
17
+
18
+
19
+ class SettingService:
20
+ """Async CRUD + scope resolution for key/value settings.
21
+
22
+ Resolution precedence when calling ``resolve`` / ``get_resolved_value``:
23
+ USER > TENANT > SYSTEM. The first match in that chain is returned.
24
+ """
25
+
26
+ def __init__(self, db: AsyncSession) -> None:
27
+ self.db = db
28
+
29
+ # ── Listing ─────────────────────────────────────────────────────
30
+
31
+ async def list_all(self) -> list[SettingOut]:
32
+ result = await self.db.execute(
33
+ select(Setting).order_by(Setting.scope, Setting.scope_id, Setting.key)
34
+ )
35
+ return [SettingOut.model_validate(row) for row in result.scalars()]
36
+
37
+ async def list_by_scope(
38
+ self, scope: SettingScope, scope_id: str = SYSTEM_SCOPE_ID
39
+ ) -> list[SettingOut]:
40
+ stmt = (
41
+ select(Setting)
42
+ .where(Setting.scope == scope.value, Setting.scope_id == scope_id)
43
+ .order_by(Setting.key)
44
+ )
45
+ result = await self.db.execute(stmt)
46
+ return [SettingOut.model_validate(row) for row in result.scalars()]
47
+
48
+ # ── Lookup ──────────────────────────────────────────────────────
49
+
50
+ async def get_by_id(self, setting_id: int) -> SettingOut | None:
51
+ entity = await self.db.get(Setting, setting_id)
52
+ if entity is None:
53
+ return None
54
+ return SettingOut.model_validate(entity)
55
+
56
+ async def get_scoped(self, scope: SettingScope, scope_id: str, key: str) -> SettingOut | None:
57
+ entity = await self._find(scope, scope_id, key)
58
+ return SettingOut.model_validate(entity) if entity is not None else None
59
+
60
+ async def resolve(
61
+ self,
62
+ key: str,
63
+ user_id: str | None = None,
64
+ tenant_id: str | None = None,
65
+ ) -> SettingOut | None:
66
+ if user_id:
67
+ entity = await self._find(SettingScope.USER, user_id, key)
68
+ if entity is not None:
69
+ return SettingOut.model_validate(entity)
70
+ if tenant_id:
71
+ entity = await self._find(SettingScope.TENANT, tenant_id, key)
72
+ if entity is not None:
73
+ return SettingOut.model_validate(entity)
74
+ entity = await self._find(SettingScope.SYSTEM, SYSTEM_SCOPE_ID, key)
75
+ return SettingOut.model_validate(entity) if entity is not None else None
76
+
77
+ async def get_resolved_value(
78
+ self,
79
+ key: str,
80
+ user_id: str | None = None,
81
+ tenant_id: str | None = None,
82
+ default: str | None = None,
83
+ ) -> str | None:
84
+ found = await self.resolve(key, user_id=user_id, tenant_id=tenant_id)
85
+ return found.value if found is not None else default
86
+
87
+ # ── Mutations ───────────────────────────────────────────────────
88
+
89
+ async def create(self, data: SettingCreate) -> SettingOut:
90
+ entity = Setting(**data.model_dump())
91
+ self.db.add(entity)
92
+ await self.db.flush()
93
+ await self.db.refresh(entity)
94
+ return SettingOut.model_validate(entity)
95
+
96
+ async def update(self, setting_id: int, data: SettingUpdate) -> SettingOut | None:
97
+ entity = await self.db.get(Setting, setting_id)
98
+ if entity is None:
99
+ return None
100
+ for field, value in data.model_dump(exclude_unset=True).items():
101
+ setattr(entity, field, value)
102
+ await self.db.flush()
103
+ await self.db.refresh(entity)
104
+ return SettingOut.model_validate(entity)
105
+
106
+ async def upsert_scoped(
107
+ self,
108
+ scope: SettingScope,
109
+ scope_id: str,
110
+ key: str,
111
+ data: SettingUpsert,
112
+ ) -> SettingOut:
113
+ entity = await self._find(scope, scope_id, key)
114
+ if entity is None:
115
+ entity = Setting(
116
+ scope=scope.value,
117
+ scope_id=scope_id,
118
+ key=key,
119
+ value=data.value,
120
+ value_type=(
121
+ data.value_type.value if data.value_type is not None else VALUE_TYPE_STRING
122
+ ),
123
+ description=data.description,
124
+ )
125
+ self.db.add(entity)
126
+ else:
127
+ entity.value = data.value
128
+ if data.value_type is not None:
129
+ entity.value_type = data.value_type.value
130
+ # Honor explicit description=None as "clear"; skip only when unset.
131
+ if "description" in data.model_fields_set:
132
+ entity.description = data.description
133
+ await self.db.flush()
134
+ await self.db.refresh(entity)
135
+ return SettingOut.model_validate(entity)
136
+
137
+ async def delete(self, setting_id: int) -> bool:
138
+ entity = await self.db.get(Setting, setting_id)
139
+ if entity is None:
140
+ return False
141
+ await self.db.delete(entity)
142
+ await self.db.flush()
143
+ return True
144
+
145
+ async def delete_scoped(self, scope: SettingScope, scope_id: str, key: str) -> bool:
146
+ entity = await self._find(scope, scope_id, key)
147
+ if entity is None:
148
+ return False
149
+ await self.db.delete(entity)
150
+ await self.db.flush()
151
+ return True
152
+
153
+ # ── Internals ───────────────────────────────────────────────────
154
+
155
+ async def _find(self, scope: SettingScope, scope_id: str, key: str) -> Setting | None:
156
+ stmt = select(Setting).where(
157
+ Setting.scope == scope.value,
158
+ Setting.scope_id == scope_id,
159
+ Setting.key == key,
160
+ )
161
+ result = await self.db.execute(stmt)
162
+ return result.scalar_one_or_none()