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-локом на отдельном соединении: каждый шаг заменяет свою таблицу целиком, два одновременных прогона затёрли бы друг друга.
80 lines
2.9 KiB
Python
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()
|