feat(api): FastAPI — auth, RFC 7807 и роуты фазы 1
Префикс /api/v1, operationId = "<tag>_<name>", чтобы Dart-клиент получил методы вроде accountsList, а не list_accounts_api_v1_accounts_get. Ошибки — application/problem+json. Auth: access-JWT на час + refresh на 30 дней, который хранится хэшем и ротируется, логин ограничен по частоте в памяти. Деньги в JSON — всегда позиционные строки (api/schemas/common.py): float в проводе потерял бы копейки, которые NUMERIC(24,10) бережёт. Тестовый harness поднимает свой Postgres через pg_ctl (pytest-postgresql), мигрирует его один раз на сессию и усекает таблицы после каждого теста.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
"""Row factories: insert core data directly, without going through a source sync.
|
||||
|
||||
Everything returns the new id. Amounts accept str/int/Decimal and are normalised to Decimal,
|
||||
so tests can write `outcome="1234.56"` and still exercise the NUMERIC(24,10) path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from itertools import count
|
||||
from typing import Any
|
||||
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
CashTxn,
|
||||
CashTxnTag,
|
||||
Category,
|
||||
Event,
|
||||
EventKind,
|
||||
EventStatus,
|
||||
Instrument,
|
||||
PriceDaily,
|
||||
RawCbrRate,
|
||||
Rule,
|
||||
RuleKind,
|
||||
RuleMatchType,
|
||||
Trip,
|
||||
)
|
||||
|
||||
_seq = count(1)
|
||||
|
||||
|
||||
def month_back(months: int) -> date:
|
||||
"""First day of the month `months` before the current one (in the deployment tz)."""
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
t = today_local()
|
||||
total = (t.year * 12 + t.month - 1) - months
|
||||
return date(total // 12, total % 12 + 1, 1)
|
||||
|
||||
|
||||
Amount = str | int | float | Decimal
|
||||
|
||||
|
||||
def money(value: Amount | None) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
async def _add(obj: Any) -> Any:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
async def make_account(
|
||||
*,
|
||||
name: str = "Карта",
|
||||
currency: str = "RUB",
|
||||
role: AccountRole = AccountRole.liquid,
|
||||
kind: AccountKind = AccountKind.zm_card,
|
||||
balance: Amount | None = 0,
|
||||
balance_as_of: datetime | None = None,
|
||||
include_in_net_worth: bool = True,
|
||||
archived: bool = False,
|
||||
mirror_of_account_id: int | None = None,
|
||||
source: str = "zenmoney",
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
acc = await _add(
|
||||
Account(
|
||||
kind=kind,
|
||||
source=source,
|
||||
source_id=source_id or f"acc-{next(_seq)}",
|
||||
name=name,
|
||||
currency=currency,
|
||||
role=role,
|
||||
balance=money(balance),
|
||||
balance_as_of=balance_as_of or datetime.now(UTC),
|
||||
include_in_net_worth=include_in_net_worth,
|
||||
archived=archived,
|
||||
mirror_of_account_id=mirror_of_account_id,
|
||||
)
|
||||
)
|
||||
return acc.id
|
||||
|
||||
|
||||
async def make_category(
|
||||
name: str, *, parent_id: int | None = None, source_id: str | None = None
|
||||
) -> int:
|
||||
cat = await _add(
|
||||
Category(
|
||||
source="zenmoney",
|
||||
source_id=source_id or f"cat-{next(_seq)}",
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
)
|
||||
return cat.id
|
||||
|
||||
|
||||
async def make_txn(
|
||||
d: date,
|
||||
*,
|
||||
income: Amount = 0,
|
||||
income_account_id: int | None = None,
|
||||
income_currency: str | None = None,
|
||||
outcome: Amount = 0,
|
||||
outcome_account_id: int | None = None,
|
||||
outcome_currency: str | None = None,
|
||||
payee: str | None = None,
|
||||
comment: str | None = None,
|
||||
mcc: int | None = None,
|
||||
hold: bool = False,
|
||||
deleted: bool = False,
|
||||
primary_category_id: int | None = None,
|
||||
tag_ids: list[int] | None = None,
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
income_d = money(income) or Decimal(0)
|
||||
outcome_d = money(outcome) or Decimal(0)
|
||||
txn = await _add(
|
||||
CashTxn(
|
||||
source="zenmoney",
|
||||
source_id=source_id or f"txn-{next(_seq)}",
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
date=d,
|
||||
income=income_d,
|
||||
income_account_id=income_account_id,
|
||||
income_currency=income_currency or ("RUB" if income_d else None),
|
||||
outcome=outcome_d,
|
||||
outcome_account_id=outcome_account_id,
|
||||
outcome_currency=outcome_currency or ("RUB" if outcome_d else None),
|
||||
payee=payee,
|
||||
comment=comment,
|
||||
mcc=mcc,
|
||||
hold=hold,
|
||||
deleted=deleted,
|
||||
primary_category_id=primary_category_id,
|
||||
)
|
||||
)
|
||||
tags = (
|
||||
tag_ids if tag_ids is not None else ([primary_category_id] if primary_category_id else [])
|
||||
)
|
||||
if tags:
|
||||
async with get_sessionmaker()() as session:
|
||||
for ord_, category_id in enumerate(tags):
|
||||
session.add(CashTxnTag(txn_id=txn.id, ord=ord_, category_id=category_id))
|
||||
await session.commit()
|
||||
return txn.id
|
||||
|
||||
|
||||
async def make_cbr_rate(rate_date: date, ccy: str, value: Amount, *, nominal: int = 1) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
RawCbrRate(
|
||||
rate_date=rate_date, ccy=ccy, nominal=nominal, value=money(value) or Decimal(0)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_rule(
|
||||
*,
|
||||
kind: RuleKind,
|
||||
match_type: RuleMatchType,
|
||||
pattern: str,
|
||||
value: str | None = None,
|
||||
enabled: bool = True,
|
||||
priority: int = 100,
|
||||
) -> int:
|
||||
rule = await _add(
|
||||
Rule(
|
||||
kind=kind,
|
||||
match_type=match_type,
|
||||
pattern=pattern,
|
||||
value=value,
|
||||
enabled=enabled,
|
||||
priority=priority,
|
||||
)
|
||||
)
|
||||
return rule.id
|
||||
|
||||
|
||||
async def make_trip(
|
||||
name: str, date_from: date, date_to: date, *, country: str | None = None
|
||||
) -> int:
|
||||
trip = await _add(Trip(name=name, date_from=date_from, date_to=date_to, country=country))
|
||||
return trip.id
|
||||
|
||||
|
||||
async def refresh(trigger: str = "test") -> Any:
|
||||
"""Run the whole metric refresh the way the worker does."""
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
entry = await refresh_all(session, trigger=trigger)
|
||||
assert entry.error is None, entry.error
|
||||
return entry
|
||||
|
||||
|
||||
async def make_instrument(
|
||||
*,
|
||||
ticker: str = "GAZP",
|
||||
name: str | None = None,
|
||||
asset_class: AssetClass = AssetClass.share,
|
||||
currency: str = "RUB",
|
||||
board: str | None = "TQBR",
|
||||
) -> int:
|
||||
instrument = await _add(
|
||||
Instrument(
|
||||
asset_class=asset_class,
|
||||
ticker=ticker,
|
||||
board=board,
|
||||
name=name or ticker,
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
return instrument.id
|
||||
|
||||
|
||||
async def make_event(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
kind: EventKind,
|
||||
instrument_id: int | None = None,
|
||||
quantity: Amount | None = None,
|
||||
price: Amount | None = None,
|
||||
amount: Amount = 0,
|
||||
currency: str = "RUB",
|
||||
fee: Amount | None = None,
|
||||
accrued_interest: Amount | None = None,
|
||||
status: EventStatus = EventStatus.confirmed,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
key = source_id or f"ev-{next(_seq)}"
|
||||
event = await _add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
quantity=money(quantity),
|
||||
price=money(price),
|
||||
price_currency=currency if price is not None else None,
|
||||
amount=money(amount) or Decimal(0),
|
||||
currency=currency,
|
||||
fee=money(fee),
|
||||
fee_currency=currency if fee is not None else None,
|
||||
accrued_interest=money(accrued_interest),
|
||||
source="tinvest",
|
||||
source_id=key,
|
||||
dedupe_key=f"tinvest:{key}",
|
||||
meta=meta,
|
||||
)
|
||||
)
|
||||
return event.id
|
||||
|
||||
|
||||
async def make_price(
|
||||
d: date,
|
||||
*,
|
||||
instrument_id: int,
|
||||
close: Amount,
|
||||
currency: str = "RUB",
|
||||
accrued_interest: Amount | None = None,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
PriceDaily(
|
||||
instrument_id=instrument_id,
|
||||
d=d,
|
||||
close=money(close) or Decimal(0),
|
||||
currency=currency,
|
||||
source="moex",
|
||||
accrued_interest=money(accrued_interest),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
Reference in New Issue
Block a user