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-локом на отдельном соединении: каждый шаг заменяет свою таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user