Files
fin-tracker/backend/src/fintracker/config.py
T
Dmitry 295438914d feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция
SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
2026-09-18 13:43:31 +03:00

80 lines
2.9 KiB
Python

"""Runtime settings, read once from the environment (and a local .env in dev)."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_REPO_ROOT = Path(__file__).resolve().parents[3]
"""Repo root: backend/src/fintracker/config.py -> ../../.. — the dev `.env` lives there."""
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# absolute first: recipes run from `backend/`, so a bare ".env" would never resolve.
# A ".env" next to the process (docker image, other layouts) still wins if present.
env_file=(_REPO_ROOT / ".env", ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
database_url: str = "postgresql+asyncpg://fintracker:fintracker@localhost:54329/fintracker"
"""SQLAlchemy async URL. Inside docker compose the host is `db`."""
jwt_secret: str = "dev-insecure-secret-change-me"
access_token_ttl_seconds: int = 3600
refresh_token_ttl_seconds: int = 30 * 86400
login_rate_limit_per_minute: int = 5
cors_origins: str = ""
"""Comma-separated origins for the Flutter web build when it is NOT served by Caddy
from the same origin (e.g. `flutter run -d chrome` during development)."""
timezone: str = "Europe/Moscow"
uploads_dir: Path = Path("/data/uploads")
web_dir: Path | None = None
"""Optional Flutter web build to serve at `/` (dev convenience; Caddy does this in prod)."""
log_level: str = "INFO"
base_currency: str = "RUB"
"""Display/metric currency; native amounts are always kept alongside."""
# --- sources: secrets from .env; rotating refresh tokens live in source_credential ---
zenmoney_token: str | None = None
"""Static access token (e.g. from zerro.app/token). Used when no OAuth client is configured."""
zenmoney_client_id: str | None = None
zenmoney_client_secret: str | None = None
zenmoney_refresh_token: str | None = None
"""Initial refresh token for OAuth rotation; later values are stored in source_credential."""
tinvest_token: str | None = None
@field_validator(
"web_dir",
"zenmoney_token",
"zenmoney_client_id",
"zenmoney_client_secret",
"zenmoney_refresh_token",
"tinvest_token",
mode="before",
)
@classmethod
def _blank_is_none(cls, v: object) -> object:
"""A blank line in `.env` (`WEB_DIR=`) means unset — not Path('.'), not an empty token."""
return None if isinstance(v, str) and not v.strip() else v
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def is_dev_secret(self) -> bool:
return self.jwt_secret.startswith("dev-insecure")
@lru_cache
def get_settings() -> Settings:
return Settings()