โ๏ธ Settings System
LibreFolio has a two-tiered settings system โ User Settings (per-user preferences) and
Global Settings (instance-wide, admin-managed) โ held together by a single declarative
catalogue, SETTINGS_REGISTRY.
A third layer, outside these two tiers: process configuration (.env)
Startup values (ports, data folder, log level, risk-engine workers) come from .env and the
environment; the admin-facing list is Configuration. How they
load:
Settings(BaseSettings)inbackend/app/config.pyreads the project-root.env; environment variables override it, names are case-sensitive, unknown keys are ignored.get_settings()builds a fresh, validated instance on every call.main.pycalls it at import, so a mistyped or out-of-range value stops the startup. It also computesDATABASE_URLand applies theLIBREFOLIO_LOG_LEVELoverride.get_data_dir()picks the data root: in test modeLIBREFOLIO_TEST_DATA_DIRorbackend/data/test(refused if it overlaps production), otherwiseLIBREFOLIO_DATA_DIR, otherwisebackend/data/prod.- Keys outside the class โ
LIBREFOLIO_DATA_DIR,LIBREFOLIO_TEST_MODE,JWT_SECRET,LIBREFOLIO_WEB_LINK_FINDER_*,LIBREFOLIO_NO_SCHEDULERโ are read fromos.environ. pydantic-settings does not export.envthere, so they arrive through the launcher:./dev.py servercopies.envinto the environment (shell values win), Compose usesenv_file:.
๐๏ธ Two storage models, kept separate on purpose
| User Settings | Global Settings | |
|---|---|---|
| Storage | Typed columns on the per-user UserSettings row |
Key-value rows in the GlobalSetting table |
| Who writes | The user themselves (PUT /api/v1/settings/user) |
Admins only (Admin tab of Settings, or PATCH /api/v1/settings/global/bulk) |
| Examples | language, base_currency, theme, avatar_url |
session_ttl_hours, enable_registration, scheduler_*, default_currency โฆ |
The split is a deliberate design decision (P2-9), not an accident of history: per-user
preferences are fixed in number and benefit from typed columns with defaults on row creation,
while global settings are an open-ended key space that admins extend without migrations.
settings_service.py (user + global CRUD) and global_settings_service.py (typed read helpers
for global keys) stay separate services for the same reason.
Admins manage global settings from the Admin tab of Settings (GlobalSettingsTab.svelte:
lock-gated, with the scheduler dialogs and CachePanel, writing through the bulk-update endpoint).
Missing rows are seeded at every startup by the lifespan (_initialize_global_settings() in
main.py); the same idempotent seeding is available from the CLI:
or via POST /api/v1/settings/global/initialize (admin only, same idempotent semantics).
How global values behave at runtime:
- Values are stored as strings next to their
value_type;get_setting_value()inglobal_settings_service.pyconverts them on read (int;bool, true fortrue/1/yes/on;json). An unparsableintreads as0; a missing row falls back toGLOBAL_SETTINGS_DEFAULTS. - Every read goes to the database, with no cache, so a saved value reaches all workers at once: this is why admin changes need no restart.
PATCH /api/v1/settings/global/bulkstores values as sent, with no type or range check. The ranges live in the UI: the scheduler dialog accepts 1โ1440 minutes and 1โ365 days, and needs at least one time and one day.
๐ SETTINGS_REGISTRY โ the single declaration point
Every known setting key is declared once in backend/app/schemas/settings.py:
@dataclass(frozen=True, slots=True)
class SettingSpec:
key: str # storage key (GlobalSetting row key or UserSettings column)
location: Literal["user", "global"] # which storage model holds it
value_type: str # "str" | "int" | "bool" | "json"
description: str
The registry exposes two namespaces mirroring the storage models:
SETTINGS_REGISTRY.userโ oneSettingSpecperUserSettingscolumn (BASE_CURRENCY,LANGUAGE,THEME,AVATAR_URL);SETTINGS_REGISTRY.global_โ oneSettingSpecperGlobalSettingkey, built by_global_spec(), which derivesvalue_typeanddescriptionfromGLOBAL_SETTINGS_DEFAULTSso the defaults table stays the single source for both seeding and metadata.
The global keys declared today, and where the Admin tab shows them (admin view):
| Key | Type | Default | In the Admin tab |
|---|---|---|---|
session_ttl_hours |
int | 24 |
Session โ Session Duration |
enable_registration |
bool | true |
Security โ Enable Registration |
require_email_verification |
bool | false |
Security โ Require Email Verification โ read-only placeholder (PLACEHOLDER_KEYS) |
max_file_upload_mb |
int | 10 |
Memory โ Max File Upload Size |
scheduler_enabled |
bool | true |
Update Job โ Scheduler Enabled (label from settings.globalSettingNames.scheduler_enabled) |
scheduler_current_price_frequency_minutes |
int | 10 |
Schedule Configuration dialog โ Refresh every |
scheduler_history_sync_times |
str | 06:00,23:00 |
Schedule Configuration dialog โ Sync times |
scheduler_history_sync_days |
str | mon,tue,wed,thu,fri,sat |
Schedule Configuration dialog โ Sync days |
scheduler_history_sync_horizon_days |
int | 14 |
Schedule Configuration dialog โ Lookback horizon |
scheduler_timezone |
str | UTC |
Schedule Configuration dialog โ Timezone |
default_currency |
str | EUR |
Defaults โ Default Currency |
default_language |
str | en |
Defaults โ Default Language |
default_theme |
str | auto |
Defaults โ Default Theme |
The five scheduler_* keys after scheduler_enabled never appear as fields: GlobalSettingsTab.svelte
hides them (SCHEDULER_HIDDEN_KEYS) and SchedulerConfigModal.svelte writes them in one bulk call.
๐ Call-site convention
Call sites reference registry constants instead of re-declaring raw string literals:
As of 2026-09-03 there are 19 such references across the four call-site files
(settings_service.py, global_settings_service.py, scheduler/settings.py,
api/v1/settings.py). The legitimate exceptions are Alembic migrations and tests that
deliberately exercise raw keys.
Adding a new setting
- Add the key to
GLOBAL_SETTINGS_DEFAULTS(global) with value/type/description โ this is what seeds fresh installs. - Add the matching constant under
SETTINGS_REGISTRY.global_via_global_spec(key). - Reference the constant at the call site. A user-facing label/hint also needs i18n keys
and, if it appears in the Global tab, a category in
GlobalSettingsTab.svelte.
๐ฑ Base currency resolution: get_effective_base_currency
settings_service.get_effective_base_currency(session, user_id) answers "which currency does
this user's portfolio report in?" with an explicit chain:
- the per-user
UserSettings.base_currencyโ wins whenever a settings row exists; - the admin-level global
default_currency; "EUR"as the last-resort constant.
New UserSettings rows are seeded from the global defaults at creation
(get_or_create_user_settings), so the global default reaches users who never chose, while an
explicit user choice always wins afterwards.
Why this helper exists (audit 08, P0-1)
It replaced a phantom base_currency global key that was never registered anywhere:
every reader silently fell back to EUR regardless of the configured default. Any new code
that needs "the user's base currency" must call this helper โ the three current consumers
are financial_math/portfolio/engine.py, portfolio_service.py, and lots_analysis_service.py.
๐งน Related: cache administration
The named-cache registry (theine TTL caches used by services) and its admin endpoints under
/api/v1/settings/cache/* are documented in Cache Registry & Admin.