Compare commits
9 Commits
3432d95927
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67b65cbdaf | ||
| 73b5922c97 | |||
| bf8d778a9f | |||
| e85889eff8 | |||
| 37881a9da1 | |||
| d3f961f1d5 | |||
| 61a9e47f4b | |||
| 3c36ab2de0 | |||
| 5a0f1c8fd9 |
@@ -10,3 +10,6 @@ from src.application.commands.purchase_request_commands import (
|
||||
GetPurchaseRequestCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
)
|
||||
from src.application.commands.upload_docs_button import (
|
||||
UploadDocsCommand,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ class CreatePurchaseRequestCommand:
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
usdt_amount: Decimal,
|
||||
rub_amount: Decimal,
|
||||
comment: str | None = None,
|
||||
target_wallet_chain: str | None = None,
|
||||
target_wallet_address: str | None = None,
|
||||
@@ -34,7 +34,7 @@ class CreatePurchaseRequestCommand:
|
||||
legal_entity = await require_legal_entity(user_id, self._unit_of_work)
|
||||
item = await self._unit_of_work.purchase_request_repository.create(
|
||||
organization_id=legal_entity.id,
|
||||
usdt_amount=usdt_amount,
|
||||
rub_amount=rub_amount,
|
||||
comment=comment,
|
||||
target_wallet_chain=target_wallet_chain,
|
||||
target_wallet_address=target_wallet_address,
|
||||
@@ -52,14 +52,14 @@ class CreatePurchaseRequestCommand:
|
||||
}
|
||||
payload = {
|
||||
'id_client': legal_entity.id,
|
||||
'usdt_amount': usdt_amount,
|
||||
'rub_amount': rub_amount,
|
||||
}
|
||||
message = {
|
||||
'event': 'purchase_created',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
self._logger.info(f'Create purchase request for organization_id={legal_entity.id } with usdt_amount={usdt_amount}')
|
||||
self._logger.info(f'Create purchase request for organization_id={legal_entity.id } with rub_amount={rub_amount}')
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,
|
||||
|
||||
@@ -22,11 +22,9 @@ class UploadDocsCommand:
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
comment: str | None = None,
|
||||
) -> None:
|
||||
legal_entity = await require_legal_entity(user_id, self._unit_of_work)
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
metadata = {
|
||||
@@ -34,15 +32,13 @@ class UploadDocsCommand:
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
payload = {
|
||||
'id_client': legal_entity.id,
|
||||
}
|
||||
payload = {}
|
||||
message = {
|
||||
'event': 'docs_upload',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
self._logger.info(f'Upload docs for organization_id={legal_entity.id}')
|
||||
self._logger.info(f'New client uploaded docs, sending notification to telegram')
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_TELEGRAM_NOTIFY_QUEUE,
|
||||
|
||||
@@ -10,8 +10,8 @@ class PurchaseRequestEntity:
|
||||
id: str
|
||||
organization_id: str
|
||||
status: str
|
||||
usdt_amount: Decimal
|
||||
rub_amount: Decimal | None
|
||||
rub_amount: Decimal
|
||||
usdt_amount: Decimal | None
|
||||
exchange_rate: Decimal | None
|
||||
service_fee_percent: Decimal | None
|
||||
comment: str | None
|
||||
|
||||
@@ -65,6 +65,8 @@ class Settings(BaseSettings):
|
||||
CSRF_COOKIE_PATH: str = '/'
|
||||
CSRF_COOKIE_DOMAIN: str | None = None
|
||||
|
||||
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*elcsa\.ru(:\d+)?$'
|
||||
|
||||
DOCS_USERNAME: str = 'admin'
|
||||
DOCS_PASSWORD: str = 'admin'
|
||||
|
||||
@@ -237,7 +239,7 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def EXCLUDED_PATHS(self) -> List[str]:
|
||||
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
|
||||
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health', '/healthcheck']
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -20,8 +20,8 @@ class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
|
||||
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
||||
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
rub_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
||||
usdt_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -49,7 +49,7 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
usdt_amount: Decimal,
|
||||
rub_amount: Decimal,
|
||||
comment: str | None,
|
||||
target_wallet_chain: str | None,
|
||||
target_wallet_address: str | None,
|
||||
@@ -59,7 +59,7 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
|
||||
id=str(ULID()),
|
||||
organization_id=organization_id,
|
||||
status='submitted',
|
||||
usdt_amount=usdt_amount,
|
||||
rub_amount=rub_amount,
|
||||
comment=comment,
|
||||
target_wallet_chain=target_wallet_chain or 'ETH',
|
||||
target_wallet_address=target_wallet_address,
|
||||
|
||||
@@ -37,9 +37,7 @@ class RabbitClient(IQueueMessanger):
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
print("check2")
|
||||
await self._ensure_connected()
|
||||
print("check3")
|
||||
await self._broker.publish(
|
||||
message,
|
||||
queue=queue,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
@@ -19,6 +20,8 @@ from src.infrastructure.wallet_balances.token_registry import (
|
||||
)
|
||||
|
||||
TIMEOUT_SECONDS = 15
|
||||
RETRY_ATTEMPTS = 2
|
||||
PRICE_CACHE_SECONDS = 30
|
||||
BLOCKSTREAM_URL = 'https://blockstream.info/api'
|
||||
TRONGRID_URL = 'https://api.trongrid.io'
|
||||
COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'
|
||||
@@ -49,6 +52,10 @@ class WalletBalance:
|
||||
|
||||
|
||||
class WalletBalanceService:
|
||||
def __init__(self) -> None:
|
||||
self._prices_cache: dict[tuple[tuple[str,str],...],tuple[float,dict[str,Decimal | None]]] = {}
|
||||
|
||||
|
||||
async def get_wallet_balances(self, wallets) -> list[WalletBalance]:
|
||||
prices = await self._get_prices(wallets)
|
||||
results = await asyncio.gather(
|
||||
@@ -193,10 +200,17 @@ class WalletBalanceService:
|
||||
for token in self._tokens_for_chain(chain):
|
||||
coin_id = get_coingecko_id(chain, token['symbol'])
|
||||
if coin_id:
|
||||
pairs[f'{chain}:{token['symbol']}'] = coin_id
|
||||
symbol = token['symbol']
|
||||
pairs[f'{chain}:{symbol}'] = coin_id
|
||||
if not pairs:
|
||||
return {}
|
||||
|
||||
cache_key = tuple(sorted(pairs.items()))
|
||||
cached = self._prices_cache.get(cache_key)
|
||||
now = time.monotonic()
|
||||
if cached is not None and now - cached[0] < PRICE_CACHE_SECONDS:
|
||||
return dict(cached[1])
|
||||
|
||||
try:
|
||||
response = await self._get_json(
|
||||
COINGECKO_URL,
|
||||
@@ -210,10 +224,12 @@ class WalletBalanceService:
|
||||
except Exception:
|
||||
return {key: None for key in pairs}
|
||||
|
||||
return {
|
||||
prices = {
|
||||
key: self._decimal_or_none((response.get(coin_id) or {}).get('usd'))
|
||||
for key, coin_id in pairs.items()
|
||||
}
|
||||
self._prices_cache[cache_key] = (now,prices)
|
||||
return prices
|
||||
|
||||
def _token_amounts(
|
||||
self,
|
||||
@@ -287,9 +303,16 @@ class WalletBalanceService:
|
||||
timeout: int = TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
for attempt in range(RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
response = await client.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
|
||||
if attempt >= RETRY_ATTEMPTS:
|
||||
raise
|
||||
await asyncio.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError('HTTP GET failed')
|
||||
|
||||
async def _post_json(
|
||||
self,
|
||||
@@ -300,9 +323,16 @@ class WalletBalanceService:
|
||||
timeout: int = TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
for attempt in range(RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (httpx.RequestError,httpx.HTTPStatusError,ValueError):
|
||||
if attempt >= RETRY_ATTEMPTS:
|
||||
raise
|
||||
await asyncio.sleep(0.2 * (attempt + 1))
|
||||
raise RuntimeError('HTTP POST failed')
|
||||
|
||||
def _tokens_for_chain(self, chain: str) -> list[dict]:
|
||||
if chain in {'ETH', 'BSC'}:
|
||||
|
||||
20
src/main.py
20
src/main.py
@@ -74,15 +74,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
path=settings.VAULT_CRYPTO_MASTER_KEY_PATH,
|
||||
)
|
||||
logger.info('Crypto master key loaded from Vault')
|
||||
yield
|
||||
logger.info(f'B2B service instance ended with id {instance_id}')
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sched = getattr(app.state,'jwt_keys_scheduler',None)
|
||||
if sched:
|
||||
sched.shutdown(wait=False)
|
||||
logger.info(f'B2B service instance ended with id {instance_id}')
|
||||
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
redoc_url=None,
|
||||
docs_url=None,
|
||||
lifespan=lifespan,
|
||||
title='B2B Service',
|
||||
title='Elcsa. B2B Service',
|
||||
version='1.0.0',
|
||||
description='Purchase requests API for legal entity client users.',
|
||||
license_info={
|
||||
@@ -111,7 +116,7 @@ app.add_middleware(
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[],
|
||||
allow_origin_regex='.*',
|
||||
allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
@@ -146,3 +151,10 @@ async def ping() -> dict[str, str]:
|
||||
'message': 'pong',
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
@app.get('/healthcheck')
|
||||
@app.get('/health')
|
||||
async def health() -> dict[str, str]:
|
||||
return {
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import (
|
||||
@@ -9,6 +10,7 @@ from src.application.commands import (
|
||||
GetPurchaseRequestCommand,
|
||||
ListOrganizationWalletsCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
UploadDocsCommand,
|
||||
)
|
||||
from src.application.contracts import ILogger, IQueueMessanger
|
||||
from src.infrastructure.wallet_balances import WalletBalanceService
|
||||
@@ -53,13 +55,18 @@ def get_list_organization_wallets_command(
|
||||
return ListOrganizationWalletsCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _wallet_balance_service() -> WalletBalanceService:
|
||||
return WalletBalanceService()
|
||||
|
||||
|
||||
def get_get_organization_wallet_balances_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> GetOrganizationWalletBalancesCommand:
|
||||
return GetOrganizationWalletBalancesCommand(
|
||||
unit_of_work=unit_of_work,
|
||||
balance_service=WalletBalanceService(),
|
||||
balance_service=_wallet_balance_service(),
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
@@ -76,3 +83,10 @@ def get_get_organization_secret_keys_command(
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> GetOrganizationSecretKeysCommand:
|
||||
return GetOrganizationSecretKeysCommand(unit_of_work=unit_of_work, logger=logger)
|
||||
|
||||
def get_upload_docs_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
messanger: IQueueMessanger = Depends(get_queue_messanger),
|
||||
) -> UploadDocsCommand:
|
||||
return UploadDocsCommand(unit_of_work=unit_of_work, logger=logger, messanger=messanger)
|
||||
|
||||
@@ -2,8 +2,10 @@ from fastapi import APIRouter
|
||||
|
||||
from src.presentation.routing.organizations import organizations_router
|
||||
from src.presentation.routing.purchase_requests import purchase_requests_router
|
||||
from src.presentation.routing.documents import upload_docs_router
|
||||
|
||||
v1_router = APIRouter(prefix='/v1')
|
||||
v1_router.include_router(organizations_router)
|
||||
v1_router.include_router(upload_docs_router)
|
||||
|
||||
__all__ = ['purchase_requests_router', 'organizations_router', 'v1_router']
|
||||
__all__ = ['purchase_requests_router', 'organizations_router', 'upload_docs_router', 'v1_router']
|
||||
|
||||
17
src/presentation/routing/documents.py
Normal file
17
src/presentation/routing/documents.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from src.application.commands import (
|
||||
UploadDocsCommand,
|
||||
)
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_upload_docs_command,
|
||||
)
|
||||
|
||||
upload_docs_router = APIRouter(prefix='/upload-docs', tags=['upload-docs'])
|
||||
|
||||
@upload_docs_router.post('')
|
||||
async def upload_docs(
|
||||
command: UploadDocsCommand = Depends(get_upload_docs_command),
|
||||
):
|
||||
await command()
|
||||
return {'message': 'Documents uploaded successfully.'}
|
||||
@@ -33,7 +33,7 @@ async def create_purchase_request(
|
||||
):
|
||||
item = await command(
|
||||
auth.user_id,
|
||||
usdt_amount=body.usdt_amount,
|
||||
rub_amount=body.rub_amount,
|
||||
comment=body.comment,
|
||||
target_wallet_chain=body.target_wallet_chain,
|
||||
target_wallet_address=body.target_wallet_address,
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreatePurchaseRequestBody(BaseModel):
|
||||
usdt_amount: Decimal = Field(gt=0)
|
||||
rub_amount: Decimal = Field(gt=0)
|
||||
comment: str | None = None
|
||||
target_wallet_chain: str | None = Field(default='ETH', max_length=16)
|
||||
target_wallet_address: str | None = Field(default=None, max_length=128)
|
||||
@@ -16,8 +16,8 @@ class PurchaseRequestResponse(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
status: str
|
||||
usdt_amount: str
|
||||
rub_amount: str | None
|
||||
rub_amount: str
|
||||
usdt_amount: str | None
|
||||
exchange_rate: str | None
|
||||
service_fee_percent: str | None
|
||||
comment: str | None
|
||||
|
||||
Reference in New Issue
Block a user