simple-module-settings 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- settings/__init__.py +1 -0
- settings/_module_settings.py +169 -0
- settings/cli.py +78 -0
- settings/constants.py +115 -0
- settings/contracts/__init__.py +24 -0
- settings/contracts/accessor.py +236 -0
- settings/contracts/events.py +20 -0
- settings/contracts/registry.py +66 -0
- settings/contracts/schemas.py +154 -0
- settings/deps.py +56 -0
- settings/endpoints/__init__.py +0 -0
- settings/endpoints/api.py +202 -0
- settings/endpoints/module_api.py +102 -0
- settings/endpoints/views.py +120 -0
- settings/env_vars.py +18 -0
- settings/hydrate.py +63 -0
- settings/locales/en.json +71 -0
- settings/models.py +50 -0
- settings/module.py +76 -0
- settings/module_registry.py +33 -0
- settings/package.json +16 -0
- settings/pages/Browse.tsx +103 -0
- settings/pages/Create.tsx +113 -0
- settings/pages/Edit.tsx +102 -0
- settings/pages/ModulesEdit.tsx +74 -0
- settings/pages/components/FieldInput.tsx +96 -0
- settings/pages/components/ModuleForm.tsx +157 -0
- settings/pages/components/ValueInput.tsx +92 -0
- settings/pages/routes.ts +7 -0
- settings/py.typed +0 -0
- settings/registration.py +34 -0
- settings/reload.py +58 -0
- settings/service.py +162 -0
- settings/services.py +36 -0
- settings/settings.py +11 -0
- settings/store.py +61 -0
- simple_module_settings-0.0.1.dist-info/METADATA +69 -0
- simple_module_settings-0.0.1.dist-info/RECORD +41 -0
- simple_module_settings-0.0.1.dist-info/WHEEL +4 -0
- simple_module_settings-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_settings-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|
settings/pages/routes.ts
ADDED
settings/py.typed
ADDED
|
File without changes
|
settings/registration.py
ADDED
|
@@ -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()
|