Compare commits

3 Commits

Author SHA1 Message Date
18c9c1e01a fix: merge conflict 2 2026-06-10 18:42:06 +03:00
4fc74f404f fix: merge conflict 2026-06-10 18:35:26 +03:00
e208792738 feat: add change password 2026-06-08 11:55:51 +03:00
40 changed files with 391 additions and 1124 deletions

View File

@@ -4,14 +4,14 @@ version = "0.1.0"
description = "Admin service for legal entities and B2B operations"
requires-python = "==3.12.*"
dependencies = [
"acryl-datahub==1.5.0.19",
"acryl-sqlglot==25.25.2.dev9",
"aiobotocore==2.21.0",
"acryl-datahub>=1.5.0.19",
"acryl-sqlglot>=25.25.2.dev9",
"aiobotocore>=2.21.0",
"apscheduler==3.11.2",
"asyncpg==0.31.0",
"bcrypt==5.0.0",
"bip-utils==2.9.3",
"cryptography==44.0.0",
"bip-utils>=2.9.3",
"cryptography>=44.0.0",
"dotenv==0.9.9",
"fastapi==0.128.7",
"granian==2.6.1",
@@ -19,7 +19,7 @@ dependencies = [
"orjson==3.11.7",
"pydantic-settings==2.12.0",
"python-jose==3.5.0",
"python-multipart==0.0.20",
"python-multipart>=0.0.20",
"python-ulid==3.1.0",
"redis==7.2.0",
"sqlalchemy==2.0.46",

View File

@@ -6,6 +6,7 @@ from src.application.abstractions.repositories import (
IAdminSessionRepository,
IAdminUserRepository,
ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
@@ -35,5 +36,8 @@ class IUnitOfWork(Protocol):
@property
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository: ...
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...

View File

@@ -3,4 +3,5 @@ from src.application.abstractions.repositories.i_admin_user_repository import IA
from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository
from src.application.abstractions.repositories.i_organization_document_repository import IOrganizationDocumentRepository
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository

View File

@@ -32,7 +32,7 @@ class ILegalEntityRepository(ABC):
raise NotImplementedError
@abstractmethod
async def list_all(self, *, limit: int, offset: int, search: str | None = None) -> list[LegalEntityEntity]:
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
raise NotImplementedError
@abstractmethod
@@ -49,13 +49,5 @@ class ILegalEntityRepository(ABC):
raise NotImplementedError
@abstractmethod
async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity:
raise NotImplementedError
@abstractmethod
async def count_all(self, *, search: str | None = None) -> int:
async def count_all(self) -> int:
raise NotImplementedError

View File

@@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.organization import OrganizationDocumentEntity
class IOrganizationDocumentRepository(ABC):
@abstractmethod
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
raise NotImplementedError
@abstractmethod
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
raise NotImplementedError

View File

@@ -5,10 +5,6 @@ from src.application.domain.entities.organization import PurchaseRequestEntity
class IPurchaseRequestRepository(ABC):
@abstractmethod
async def create(self, values: dict[str, Any]) -> PurchaseRequestEntity:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
raise NotImplementedError

View File

@@ -34,3 +34,11 @@ class IUserRepository(ABC):
@abstractmethod
async def count_individuals(self, *, query: str) -> int:
raise NotImplementedError
@abstractmethod
async def get_password_hash(self, user_id: str) -> str:
raise NotImplementedError
@abstractmethod
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
raise NotImplementedError

View File

@@ -1,5 +1,3 @@
from src.application.commands.admin_login import AdminLoginCommand
from src.application.commands.admin_logout import AdminLogoutCommand
from src.application.commands.admin_jwt_refresh import AdminJwtRefreshCommand
from src.application.commands.get_admin_me import GetAdminMeCommand
from src.application.commands.create_organization import CreateOrganizationCommand
@@ -21,7 +19,6 @@ from src.application.commands.organization_wallet_commands import (
ListOrganizationWalletsCommand,
)
from src.application.commands.purchase_request_commands import (
CreatePurchaseRequestCommand,
ListPurchaseRequestsCommand,
GetPurchaseRequestCommand,
UpdatePurchaseRequestCommand,
@@ -41,7 +38,6 @@ __all__ = [
'GetOrganizationCommand',
'SearchPartiesCommand',
'UpdateOrganizationCommand',
'CreatePurchaseRequestCommand',
'ListPurchaseRequestsCommand',
'GetPurchaseRequestCommand',
'UpdatePurchaseRequestCommand',

View File

@@ -4,10 +4,7 @@ from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import (
CreateOrganizationWalletsResult,
OrganizationWalletEntity,
)
from src.application.domain.entities.organization import OrganizationWalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.crypto.wallet_crypto import (
ALL_CHAINS,
@@ -25,7 +22,7 @@ class CreateOrganizationWalletsCommand:
self._logger = logger
@transactional
async def __call__(self, *, organization_id: str) -> CreateOrganizationWalletsResult:
async def __call__(self, *, organization_id: str) -> list[OrganizationWalletEntity]:
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
@@ -38,7 +35,6 @@ class CreateOrganizationWalletsCommand:
mnemonic = generate_mnemonic()
derived = derive_all_addresses(mnemonic)
plaintext_mnemonic = mnemonic
blob = encrypt_mnemonic(mnemonic)
mnemonic = ''
@@ -57,4 +53,4 @@ class CreateOrganizationWalletsCommand:
]
saved = await self._unit_of_work.organization_wallet_repository.create_many(wallets)
self._logger.info(f'Wallets created for organization_id={organization_id} chains={len(saved)}')
return CreateOrganizationWalletsResult(wallets=saved, mnemonic=plaintext_mnemonic)
return saved

View File

@@ -4,8 +4,7 @@ from typing import Any
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity, PartySearchEntity
from src.application.domain.enums.account_type import AccountType
from src.application.domain.entities.organization import LegalEntityEntity
from src.infrastructure.database.decorators import transactional
@@ -15,65 +14,12 @@ class ListOrganizationsCommand:
self._logger = logger
@transactional
async def __call__(
self,
*,
limit: int = 50,
offset: int = 0,
search: str | None = None,
) -> tuple[list[LegalEntityEntity], int]:
items = await self._unit_of_work.legal_entity_repository.list_all(
limit=limit,
offset=offset,
search=search,
)
total = await self._unit_of_work.legal_entity_repository.count_all(search=search)
async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]:
items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset)
total = await self._unit_of_work.legal_entity_repository.count_all()
return items, total
class SearchPartiesCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, *, query: str, limit: int = 50, offset: int = 0) -> tuple[list[PartySearchEntity], int]:
query = query.strip()
if not query:
return [], 0
fetch_limit = limit + offset
legal_items = await self._unit_of_work.legal_entity_repository.list_all(
limit=fetch_limit,
offset=0,
search=query,
)
individual_items = await self._unit_of_work.user_repository.search_individuals(
query=query,
limit=fetch_limit,
offset=0,
)
legal_total = await self._unit_of_work.legal_entity_repository.count_all(search=query)
individual_total = await self._unit_of_work.user_repository.count_individuals(query=query)
items = [self._legal_to_search_entity(item) for item in legal_items] + individual_items
items.sort(key=lambda item: item.created_at.timestamp() if item.created_at else 0, reverse=True)
return items[offset:offset + limit], legal_total + individual_total
def _legal_to_search_entity(self, item: LegalEntityEntity) -> PartySearchEntity:
return PartySearchEntity(
id=item.id,
account_type=AccountType.LEGAL_ENTITY,
user_id=item.user_id,
email=None,
name=item.name,
inn=item.inn,
phone=item.contact_phone,
status=item.status,
kyc_verified=item.kyc_verified,
created_at=item.created_at,
)
class GetOrganizationCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work

View File

@@ -2,11 +2,9 @@ from __future__ import annotations
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentSlot
from src.application.domain.entities.organization import OrganizationDocumentEntity
from src.application.domain.exceptions import ApplicationException
from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES
from src.infrastructure.database.decorators import transactional
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
class ListOrganizationDocumentsCommand:
@@ -15,20 +13,9 @@ class ListOrganizationDocumentsCommand:
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> list[OrganizationDocumentSlot]:
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
slots: list[OrganizationDocumentSlot] = []
for document_type in ORGANIZATION_DOCUMENT_TYPES:
s3_key = org.document_s3_keys.get(document_type)
slots.append(
OrganizationDocumentSlot(
organization_id=organization_id,
document_type=document_type,
s3_key=s3_key,
file_name=S3DocumentsService.file_name_from_key(s3_key) if s3_key else None,
)
)
return slots
async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]:
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
return await self._unit_of_work.organization_document_repository.list_by_organization(organization_id)
class GetOrganizationDocumentCommand:
@@ -37,18 +24,8 @@ class GetOrganizationDocumentCommand:
self._logger = logger
@transactional
async def __call__(self, organization_id: str, document_type: str) -> OrganizationDocumentSlot:
if document_type not in ORGANIZATION_DOCUMENT_TYPES:
raise ApplicationException(status_code=400, message='Invalid document type')
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
s3_key = org.document_s3_keys.get(document_type)
if not s3_key:
raise ApplicationException(status_code=404, message='Document not uploaded')
return OrganizationDocumentSlot(
organization_id=organization_id,
document_type=document_type,
s3_key=s3_key,
file_name=S3DocumentsService.file_name_from_key(s3_key),
)
async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity:
doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id)
if doc.organization_id != organization_id:
raise ApplicationException(status_code=404, message='Document not found')
return doc

View File

@@ -1,63 +0,0 @@
from __future__ import annotations
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity, OrganizationWalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.crypto.wallet_crypto import (
DerivedSecretKey,
decrypt_mnemonic,
derive_all_private_keys,
is_crypto_ready,
)
from src.infrastructure.database.decorators import transactional
class ListOrganizationWalletsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> list[OrganizationWalletEntity]:
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
return await self._unit_of_work.organization_wallet_repository.list_by_organization(organization_id)
class GetOrganizationMnemonicCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> str:
org = await self._require_wallets_org(organization_id)
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
return decrypt_mnemonic(org.encrypted_mnemonic or '')
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
if not org.encrypted_mnemonic:
raise ApplicationException(status_code=404, message='Wallets not created for organization')
return org
class GetOrganizationSecretKeysCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> list[DerivedSecretKey]:
org = await self._require_wallets_org(organization_id)
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
mnemonic = decrypt_mnemonic(org.encrypted_mnemonic or '')
return derive_all_private_keys(mnemonic)
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
if not org.encrypted_mnemonic:
raise ApplicationException(status_code=404, message='Wallets not created for organization')
return org

View File

@@ -54,54 +54,6 @@ class GetPurchaseRequestCommand:
return await self._unit_of_work.purchase_request_repository.get_by_id(request_id)
class CreatePurchaseRequestCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(
self,
*,
organization_id: str,
usdt_amount: Decimal,
status: str = 'submitted',
rub_amount: Decimal | None = None,
exchange_rate: Decimal | None = None,
service_fee_percent: Decimal | None = None,
comment: str | None = None,
admin_comment: str | None = None,
target_wallet_chain: str | None = 'ETH',
target_wallet_address: str | None = None,
tx_hash: str | None = None,
assigned_to: str | None = None,
) -> PurchaseRequestEntity:
if status not in VALID_STATUSES:
raise ApplicationException(status_code=400, message='Invalid status')
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
values: dict[str, Any] = {
'organization_id': organization_id,
'status': status,
'usdt_amount': usdt_amount,
'rub_amount': rub_amount,
'exchange_rate': exchange_rate,
'service_fee_percent': service_fee_percent,
'comment': comment,
'admin_comment': admin_comment,
'target_wallet_chain': target_wallet_chain,
'target_wallet_address': target_wallet_address,
'tx_hash': tx_hash,
'assigned_to': assigned_to,
}
if status == 'completed':
values['completed_at'] = datetime.now(timezone.utc)
created = await self._unit_of_work.purchase_request_repository.create(values)
self._logger.info(f'Purchase request created id={created.id} org={organization_id}')
return created
class UpdatePurchaseRequestStatusCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
@@ -129,46 +81,10 @@ class UpdatePurchaseRequestStatusCommand:
values['tx_hash'] = tx_hash
if status == 'completed':
values['completed_at'] = datetime.now(timezone.utc)
else:
values['completed_at'] = None
return await self._unit_of_work.purchase_request_repository.update(request_id, values=values)
class UpdatePurchaseRequestCommand:
ALLOWED_FIELDS = frozenset({
'status',
'usdt_amount',
'rub_amount',
'exchange_rate',
'service_fee_percent',
'comment',
'admin_comment',
'target_wallet_chain',
'target_wallet_address',
'tx_hash',
'assigned_to',
})
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity:
filtered = {k: v for k, v in values.items() if k in self.ALLOWED_FIELDS}
status = filtered.get('status')
if status is not None:
if status not in VALID_STATUSES:
raise ApplicationException(status_code=400, message='Invalid status')
if status == 'completed':
filtered['completed_at'] = datetime.now(timezone.utc)
else:
filtered['completed_at'] = None
return await self._unit_of_work.purchase_request_repository.update(request_id, values=filtered)
class SetPurchaseRequestQuoteCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work

View File

@@ -1,73 +0,0 @@
from __future__ import annotations
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentSlot
from src.application.domain.exceptions import ApplicationException
from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES
from src.infrastructure.database.decorators import transactional
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
class PutOrganizationDocumentCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
s3_service: S3DocumentsService,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._s3 = s3_service
self._logger = logger
@transactional
async def __call__(
self,
*,
organization_id: str,
document_type: str,
file_name: str,
content_type: str,
body: bytes,
) -> OrganizationDocumentSlot:
if document_type not in ORGANIZATION_DOCUMENT_TYPES:
raise ApplicationException(status_code=400, message='Invalid document type')
if not body:
raise ApplicationException(status_code=400, message='File is empty')
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
old_s3_key = await self._unit_of_work.legal_entity_repository.get_document_s3_key(
organization_id,
document_type,
)
new_s3_key = self._s3.build_object_key(organization_id, document_type, file_name)
await self._s3.upload_bytes(key=new_s3_key, body=body, content_type=content_type)
try:
await self._unit_of_work.legal_entity_repository.set_document_s3_key(
organization_id,
document_type,
new_s3_key,
)
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
await self._s3.delete_object(key=new_s3_key)
raise
if old_s3_key and old_s3_key != new_s3_key:
await self._s3.delete_object(key=old_s3_key)
self._logger.info(
f'Document uploaded org={organization_id} type={document_type} key={new_s3_key}'
)
return OrganizationDocumentSlot(
organization_id=organization_id,
document_type=document_type,
s3_key=new_s3_key,
file_name=self._s3.file_name_from_key(new_s3_key),
content_type=content_type,
file_size_bytes=len(body),
)

View File

@@ -0,0 +1,31 @@
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class SetPasswordCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
hash_service: IHashService,
logger: ILogger
):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._logger = logger
@transactional
async def __call__(self, email: str, password: str) -> bool:
try:
user = await self._unit_of_work.user_repository.get_user_by_email(email)
password_hash = self._hash_service.hash_password(password)
await self._unit_of_work.user_repository.set_password(
user_id=user.id,
password_hash=password_hash,
)
self._logger.info(f'Set password for user {user.id}')
return True
except ApplicationException:
raise

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
ALLOWED_DOCUMENT_TYPES = frozenset({
'charter', 'inn_certificate', 'ogrn_certificate',
'bank_details', 'kyc_representative', 'power_of_attorney', 'other',
})
class UploadOrganizationDocumentCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
s3_service: S3DocumentsService,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._s3 = s3_service
self._logger = logger
@transactional
async def __call__(
self,
*,
organization_id: str,
admin_user_id: str,
document_type: str,
file_name: str,
content_type: str,
body: bytes,
) -> OrganizationDocumentEntity:
if document_type not in ALLOWED_DOCUMENT_TYPES:
raise ApplicationException(status_code=400, message='Invalid document type')
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
document_id = str(ULID())
s3_key = self._s3.build_object_key(organization_id, document_id, file_name)
await self._s3.upload_bytes(key=s3_key, body=body, content_type=content_type)
entity = OrganizationDocumentEntity(
id=document_id,
organization_id=organization_id,
document_type=document_type,
file_name=file_name,
s3_key=s3_key,
content_type=content_type,
file_size_bytes=len(body),
uploaded_by=admin_user_id,
)
saved = await self._unit_of_work.organization_document_repository.create(entity)
self._logger.info(f'Document uploaded id={saved.id} org={organization_id}')
return saved

View File

@@ -24,7 +24,6 @@ class LegalEntityEntity:
kyc_verified: bool
kyc_verified_at: datetime | None
encrypted_mnemonic: str | None
document_s3_keys: dict[str, str | None]
created_by: str | None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -41,32 +40,15 @@ class OrganizationWalletEntity:
@dataclass
class CreateOrganizationWalletsResult:
wallets: list[OrganizationWalletEntity]
mnemonic: str
@dataclass
class OrganizationDocumentSlot:
class OrganizationDocumentEntity:
id: str
organization_id: str
document_type: str
s3_key: str | None
file_name: str | None = None
content_type: str | None = None
file_size_bytes: int | None = None
@dataclass
class PartySearchEntity:
id: str
account_type: str
user_id: str
email: str | None
name: str | None
inn: str | None
phone: str | None
status: str | None = None
kyc_verified: bool | None = None
file_name: str
s3_key: str
content_type: str
file_size_bytes: int
uploaded_by: str | None
created_at: datetime | None = None

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
ORGANIZATION_DOCUMENT_TYPES: tuple[str, ...] = (
'charter',
'inn_certificate',
'ogrn_certificate',
'bank_details',
'kyc_representative',
'power_of_attorney',
'other',
)
DOCUMENT_TYPE_TO_COLUMN: dict[str, str] = {
'charter': 'charter_s3_key',
'inn_certificate': 'inn_certificate_s3_key',
'ogrn_certificate': 'ogrn_certificate_s3_key',
'bank_details': 'bank_details_document_s3_key',
'kyc_representative': 'kyc_representative_s3_key',
'power_of_attorney': 'power_of_attorney_s3_key',
'other': 'other_document_s3_key',
}
DOCUMENT_TYPE_URL_SLUGS: dict[str, str] = {
'charter': 'charter',
'inn_certificate': 'inn-certificate',
'ogrn_certificate': 'ogrn-certificate',
'bank_details': 'bank-details',
'kyc_representative': 'kyc-representative',
'power_of_attorney': 'power-of-attorney',
'other': 'other',
}
URL_SLUG_TO_DOCUMENT_TYPE: dict[str, str] = {
slug: document_type for document_type, slug in DOCUMENT_TYPE_URL_SLUGS.items()
}

View File

@@ -0,0 +1,21 @@
import re
SPECIAL_CHARS = '!@#$%^&*()_+-=.,:;?/[]{}<>'
def validate_password_strength(password: str) -> str:
if re.search(r'\s', password):
raise ValueError('Password must not contain whitespace')
if len(password) < 12:
raise ValueError('Password must be at least 12 characters')
if not re.search(r'[a-z]', password):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[A-Z]', password):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'\d', password):
raise ValueError('Password must contain at least one digit')
if not any(c in SPECIAL_CHARS for c in password):
raise ValueError(
'Password must contain at least one special character from: !@#$%^&*()_+-=.,:;?/[]{}<>'
)
return password

View File

@@ -81,7 +81,7 @@ class Settings(BaseSettings):
RATE_LIMIT_WINDOW: int = 60
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'TEXT'
@field_validator('VAULT_ADDR', mode='before')
@classmethod

View File

@@ -43,14 +43,6 @@ class DerivedWallet:
derivation_path: str
@dataclass(frozen=True)
class DerivedSecretKey:
chain: str
address: str
derivation_path: str
private_key: str
class CryptoNotReadyError(RuntimeError):
pass
@@ -159,87 +151,6 @@ def derive_all_addresses(mnemonic: str) -> list[DerivedWallet]:
]
def derive_all_private_keys(mnemonic: str) -> list[DerivedSecretKey]:
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
eth_node = (
eth_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
)
eth_addr = eth_node.PublicKey().ToAddress()
eth_pk = eth_node.PrivateKey().Raw().ToHex()
btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
btc_node = (
btc_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
)
btc_addr = btc_node.PublicKey().ToAddress()
btc_pk = btc_node.PrivateKey().Raw().ToHex()
trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON)
trx_node = (
trx_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
)
trx_addr = trx_node.PublicKey().ToAddress()
trx_pk = trx_node.PrivateKey().Raw().ToHex()
sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
sol_node = (
sol_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
)
sol_addr = sol_node.PublicKey().ToAddress()
sol_pk = sol_node.PrivateKey().Raw().ToHex()
return [
DerivedSecretKey(
chain='ETH',
address=eth_addr,
derivation_path=DERIVATION_PATHS['ETH'],
private_key=eth_pk,
),
DerivedSecretKey(
chain='BSC',
address=eth_addr,
derivation_path=DERIVATION_PATHS['BSC'],
private_key=eth_pk,
),
DerivedSecretKey(
chain='BTC',
address=btc_addr,
derivation_path=DERIVATION_PATHS['BTC'],
private_key=btc_pk,
),
DerivedSecretKey(
chain='TRX',
address=trx_addr,
derivation_path=DERIVATION_PATHS['TRX'],
private_key=trx_pk,
),
DerivedSecretKey(
chain='SOL',
address=sol_addr,
derivation_path=DERIVATION_PATHS['SOL'],
private_key=sol_pk,
),
]
def encrypt_mnemonic(plaintext: str) -> str:
if _master_key is None:
raise CryptoNotReadyError('Crypto service not ready')

View File

@@ -4,6 +4,7 @@ from src.infrastructure.database.models.admin_user import AdminUserModel
from src.infrastructure.database.models.admin_session import AdminSessionModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel
from src.infrastructure.database.models.organization_document import OrganizationDocumentModel
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
__all__ = [
@@ -13,5 +14,6 @@ __all__ = [
'AdminSessionModel',
'LegalEntityModel',
'OrganizationWalletModel',
'OrganizationDocumentModel',
'PurchaseRequestModel',
]

View File

@@ -35,13 +35,6 @@ class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
charter_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
inn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
ogrn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
bank_details_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
kyc_representative_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
power_of_attorney_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
other_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
created_by: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'organization_documents'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
document_type: Mapped[str] = mapped_column(String(64), nullable=False)
file_name: Mapped[str] = mapped_column(String(512), nullable=False)
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
uploaded_by: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -3,6 +3,7 @@ from src.infrastructure.database.repositories.admin_session_repository import Ad
from src.infrastructure.database.repositories.user_repository import UserRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
__all__ = [
@@ -11,5 +12,6 @@ __all__ = [
'UserRepository',
'LegalEntityRepository',
'OrganizationWalletRepository',
'OrganizationDocumentRepository',
'PurchaseRequestRepository',
]

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
from typing import Any
from sqlalchemy import func, or_, select, update
from fastapi import status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
@@ -10,8 +11,7 @@ from src.application.abstractions.repositories import ILegalEntityRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException
from src.application.domain.organization_documents import DOCUMENT_TYPE_TO_COLUMN, ORGANIZATION_DOCUMENT_TYPES
from src.infrastructure.database.models import LegalEntityModel, UserModel
from src.infrastructure.database.models import LegalEntityModel
class LegalEntityRepository(ILegalEntityRepository):
@@ -19,12 +19,6 @@ class LegalEntityRepository(ILegalEntityRepository):
self._session = session
self._logger = logger
def _document_s3_keys_from_model(self, m: LegalEntityModel) -> dict[str, str | None]:
return {
document_type: getattr(m, DOCUMENT_TYPE_TO_COLUMN[document_type])
for document_type in ORGANIZATION_DOCUMENT_TYPES
}
def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity(
id=m.id,
@@ -43,43 +37,11 @@ class LegalEntityRepository(ILegalEntityRepository):
kyc_verified=m.kyc_verified,
kyc_verified_at=m.kyc_verified_at,
encrypted_mnemonic=m.encrypted_mnemonic,
document_s3_keys=self._document_s3_keys_from_model(m),
created_by=m.created_by,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _search_filter(self, search: str | None):
if not search or not search.strip():
return None
pattern = f'%{search.strip()}%'
full_name = func.concat_ws(
' ',
UserModel.last_name,
UserModel.first_name,
UserModel.middle_name,
)
return or_(
LegalEntityModel.id.ilike(pattern),
LegalEntityModel.name.ilike(pattern),
LegalEntityModel.short_name.ilike(pattern),
LegalEntityModel.inn.ilike(pattern),
LegalEntityModel.ogrn.ilike(pattern),
LegalEntityModel.kpp.ilike(pattern),
LegalEntityModel.legal_address.ilike(pattern),
LegalEntityModel.actual_address.ilike(pattern),
LegalEntityModel.contact_person.ilike(pattern),
LegalEntityModel.contact_phone.ilike(pattern),
UserModel.email.ilike(pattern),
UserModel.last_name.ilike(pattern),
UserModel.first_name.ilike(pattern),
UserModel.middle_name.ilike(pattern),
UserModel.phone.ilike(pattern),
UserModel.inn.ilike(pattern),
full_name.ilike(pattern),
)
async def create(
self,
*,
@@ -133,37 +95,14 @@ class LegalEntityRepository(ILegalEntityRepository):
raise ApplicationException(status_code=404, message='Organization not found')
return self._to_entity(m)
async def list_all(
self,
*,
limit: int,
offset: int,
search: str | None = None,
) -> list[LegalEntityEntity]:
stmt = (
select(LegalEntityModel)
.join(UserModel, UserModel.id == LegalEntityModel.user_id)
.where(UserModel.is_deleted.is_(False))
.order_by(LegalEntityModel.created_at.desc())
.limit(limit)
.offset(offset)
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
res = await self._session.execute(
select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset)
)
search_filter = self._search_filter(search)
if search_filter is not None:
stmt = stmt.where(search_filter)
res = await self._session.execute(stmt)
return [self._to_entity(m) for m in res.scalars().all()]
async def count_all(self, *, search: str | None = None) -> int:
stmt = (
select(func.count(LegalEntityModel.id))
.join(UserModel, UserModel.id == LegalEntityModel.user_id)
.where(UserModel.is_deleted.is_(False))
)
search_filter = self._search_filter(search)
if search_filter is not None:
stmt = stmt.where(search_filter)
res = await self._session.execute(stmt)
async def count_all(self) -> int:
res = await self._session.execute(select(func.count()).select_from(LegalEntityModel))
return int(res.scalar_one())
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
@@ -182,19 +121,3 @@ class LegalEntityRepository(ILegalEntityRepository):
.values(encrypted_mnemonic=encrypted_mnemonic)
)
await self._session.flush()
async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None:
org = await self.get_by_id(organization_id)
return org.document_s3_keys.get(document_type)
async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity:
column = DOCUMENT_TYPE_TO_COLUMN.get(document_type)
if column is None:
raise ApplicationException(status_code=400, message='Invalid document type')
await self._session.execute(
update(LegalEntityModel)
.where(LegalEntityModel.id == organization_id)
.values(**{column: s3_key})
)
await self._session.flush()
return await self.get_by_id(organization_id)

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories import IOrganizationDocumentRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import OrganizationDocumentModel
class OrganizationDocumentRepository(IOrganizationDocumentRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity:
return OrganizationDocumentEntity(
id=m.id,
organization_id=m.organization_id,
document_type=m.document_type,
file_name=m.file_name,
s3_key=m.s3_key,
content_type=m.content_type,
file_size_bytes=m.file_size_bytes,
uploaded_by=m.uploaded_by,
created_at=m.created_at,
)
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
model = OrganizationDocumentModel(
id=document.id,
organization_id=document.organization_id,
document_type=document.document_type,
file_name=document.file_name,
s3_key=document.s3_key,
content_type=document.content_type,
file_size_bytes=document.file_size_bytes,
uploaded_by=document.uploaded_by,
)
self._session.add(model)
try:
await self._session.flush()
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
res = await self._session.execute(
select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id)
)
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Document not found')
return self._to_entity(m)
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
res = await self._session.execute(
select(OrganizationDocumentModel)
.where(OrganizationDocumentModel.organization_id == organization_id)
.order_by(OrganizationDocumentModel.created_at.desc())
)
return [self._to_entity(m) for m in res.scalars().all()]

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any
from ulid import ULID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
@@ -46,16 +45,6 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id)
return stmt
async def create(self, values: dict[str, Any]) -> PurchaseRequestEntity:
model = PurchaseRequestModel(id=str(ULID()), **values)
self._session.add(model)
try:
await self._session.flush()
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id))
m = res.scalar_one_or_none()

View File

@@ -3,16 +3,15 @@ from __future__ import annotations
from datetime import datetime
from fastapi import status
from sqlalchemy import func, or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities import UserEntity
from src.application.domain.entities.organization import PartySearchEntity
from src.application.domain.enums.account_type import AccountType
from src.application.domain.exceptions import ApplicationException
from src.application.domain.exceptions import InternalServerException, ApplicationException
from src.infrastructure.database.models import UserModel
@@ -75,6 +74,23 @@ class UserRepository(IUserRepository):
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
try:
user = await self._get_active_user(user_id)
for key, value in fields.items():
setattr(user, key, value)
await self._session.flush()
await self._session.refresh(user)
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise InternalServerException(message=f'Database error: {str(exception)}')
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
return await self._update_field(user_id, password_hash=password_hash)
async def get_user_by_email(self, email: str) -> UserEntity:
try:
stmt = select(UserModel).where(UserModel.email == email, UserModel.is_deleted.is_(False))
@@ -93,69 +109,3 @@ class UserRepository(IUserRepository):
stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1)
result = await self._session.execute(stmt)
return result.scalar_one_or_none() is not None
def _individual_search_filter(self, query: str):
pattern = f'%{query.strip()}%'
full_name = func.concat_ws(
' ',
UserModel.last_name,
UserModel.first_name,
UserModel.middle_name,
)
return or_(
UserModel.id.ilike(pattern),
UserModel.email.ilike(pattern),
UserModel.last_name.ilike(pattern),
UserModel.first_name.ilike(pattern),
UserModel.middle_name.ilike(pattern),
UserModel.phone.ilike(pattern),
UserModel.passport_data.ilike(pattern),
UserModel.inn.ilike(pattern),
UserModel.erc20.ilike(pattern),
full_name.ilike(pattern),
)
def _to_search_entity(self, user: UserModel) -> PartySearchEntity:
name = ' '.join(
part for part in (user.last_name, user.first_name, user.middle_name)
if part
) or None
return PartySearchEntity(
id=user.id,
account_type=user.account_type,
user_id=user.id,
email=user.email,
name=name,
inn=user.inn,
phone=user.phone,
status=None,
kyc_verified=user.kyc_verified,
created_at=user.created_at,
)
async def search_individuals(self, *, query: str, limit: int, offset: int) -> list[PartySearchEntity]:
stmt = (
select(UserModel)
.where(
UserModel.is_deleted.is_(False),
UserModel.account_type == AccountType.INDIVIDUAL,
self._individual_search_filter(query),
)
.order_by(UserModel.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await self._session.execute(stmt)
return [self._to_search_entity(user) for user in result.scalars().all()]
async def count_individuals(self, *, query: str) -> int:
stmt = (
select(func.count(UserModel.id))
.where(
UserModel.is_deleted.is_(False),
UserModel.account_type == AccountType.INDIVIDUAL,
self._individual_search_filter(query),
)
)
result = await self._session.execute(stmt)
return int(result.scalar_one())

View File

@@ -5,6 +5,7 @@ from src.application.abstractions.repositories import (
IAdminSessionRepository,
IAdminUserRepository,
ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
@@ -15,6 +16,7 @@ from src.infrastructure.database.repositories import (
AdminSessionRepository,
AdminUserRepository,
LegalEntityRepository,
OrganizationDocumentRepository,
OrganizationWalletRepository,
PurchaseRequestRepository,
UserRepository,
@@ -30,6 +32,7 @@ class UnitOfWork(IUnitOfWork):
self._admin_session_repository: IAdminSessionRepository | None = None
self._legal_entity_repository: ILegalEntityRepository | None = None
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
self._organization_document_repository: IOrganizationDocumentRepository | None = None
self._purchase_request_repository: IPurchaseRequestRepository | None = None
self._logger: ILogger = logger
@@ -85,6 +88,14 @@ class UnitOfWork(IUnitOfWork):
)
return self._organization_wallet_repository
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository:
if self._organization_document_repository is None:
self._organization_document_repository = OrganizationDocumentRepository(
session=self._session, logger=self._logger
)
return self._organization_document_repository
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository:
if self._purchase_request_repository is None:

View File

@@ -23,13 +23,9 @@ class S3DocumentsService:
self._key_prefix = key_prefix.strip('/')
self._presigned_ttl_seconds = presigned_ttl_seconds
@staticmethod
def file_name_from_key(key: str) -> str:
return key.rsplit('/', 1)[-1]
def build_object_key(self, organization_id: str, document_type: str, file_name: str) -> str:
def build_object_key(self, organization_id: str, document_id: str, file_name: str) -> str:
safe_name = file_name.replace('/', '_').replace('\\', '_')
return f'{self._key_prefix}/{organization_id}/{document_type}/{safe_name}'
return f'{self._key_prefix}/{organization_id}/{document_id}/{safe_name}'
def _client_kwargs(self) -> dict[str, object]:
kw: dict[str, object] = {'region_name': self._region}

View File

@@ -7,7 +7,6 @@ from src.application.commands import (
AdminLoginCommand,
AdminLogoutCommand,
AdminJwtRefreshCommand,
CreatePurchaseRequestCommand,
GetAdminMeCommand,
CreateOrganizationCommand,
CreateOrganizationWalletsCommand,
@@ -174,13 +173,6 @@ def get_list_purchase_requests_command(
return ListPurchaseRequestsCommand(uow, logger)
def get_create_purchase_request_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> CreatePurchaseRequestCommand:
return CreatePurchaseRequestCommand(uow, logger)
def get_get_purchase_request_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),

View File

@@ -5,6 +5,7 @@ from src.presentation.routing.documents import documents_router
from src.presentation.routing.jwt import jwt_router
from src.presentation.routing.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_router
from src.presentation.routing.users import users_router
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(auth_router)
@@ -12,3 +13,4 @@ v1_router.include_router(jwt_router)
v1_router.include_router(organizations_router)
v1_router.include_router(documents_router)
v1_router.include_router(purchase_requests_router)
v1_router.include_router(users_router)

View File

@@ -1,17 +1,17 @@
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from src.application.commands import (
GetOrganizationDocumentCommand,
ListOrganizationDocumentsCommand,
PutOrganizationDocumentCommand,
UploadOrganizationDocumentCommand,
)
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import (
get_get_organization_document_command,
get_list_organization_documents_command,
get_put_organization_document_command,
get_s3_documents_service,
get_upload_organization_document_command,
)
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
from src.presentation.schemas.mappers import document_to_response
@@ -20,12 +20,6 @@ from src.presentation.schemas.organization import DocumentResponse
documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents'])
async def _document_download_url(s3: S3DocumentsService, s3_key: str | None) -> str | None:
if not s3_key:
return None
return await s3.generate_presigned_download_url(key=s3_key)
@documents_router.get('', response_model=list[DocumentResponse])
async def list_documents(
organization_id: str,
@@ -36,226 +30,41 @@ async def list_documents(
docs = await command(organization_id)
result: list[DocumentResponse] = []
for doc in docs:
url = await _document_download_url(s3, doc.s3_key)
url = await s3.generate_presigned_download_url(key=doc.s3_key)
result.append(document_to_response(doc, download_url=url))
return result
async def _put_document(
*,
@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
organization_id: str,
document_type: str,
file: UploadFile,
command: PutOrganizationDocumentCommand,
s3: S3DocumentsService,
) -> DocumentResponse:
document_type: str = Form(...),
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
body = await file.read()
saved = await command(
organization_id=organization_id,
admin_user_id=auth.admin_user_id,
document_type=document_type,
file_name=file.filename or 'document',
content_type=file.content_type or 'application/octet-stream',
body=body,
)
url = await _document_download_url(s3, saved.s3_key)
url = await s3.generate_presigned_download_url(key=saved.s3_key)
return document_to_response(saved, download_url=url)
@documents_router.put('/charter', response_model=DocumentResponse)
async def put_charter_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='charter',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/inn-certificate', response_model=DocumentResponse)
async def put_inn_certificate_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='inn_certificate',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/ogrn-certificate', response_model=DocumentResponse)
async def put_ogrn_certificate_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='ogrn_certificate',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/bank-details', response_model=DocumentResponse)
async def put_bank_details_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='bank_details',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/kyc-representative', response_model=DocumentResponse)
async def put_kyc_representative_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='kyc_representative',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/power-of-attorney', response_model=DocumentResponse)
async def put_power_of_attorney_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='power_of_attorney',
file=file,
command=command,
s3=s3,
)
@documents_router.put('/other', response_model=DocumentResponse)
async def put_other_document(
organization_id: str,
file: UploadFile = File(...),
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _put_document(
organization_id=organization_id,
document_type='other',
file=file,
command=command,
s3=s3,
)
@documents_router.get('/charter', response_model=DocumentResponse)
async def get_charter_document(
@documents_router.get('/{document_id}', response_model=DocumentResponse)
async def get_document(
organization_id: str,
document_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'charter', command, s3)
@documents_router.get('/inn-certificate', response_model=DocumentResponse)
async def get_inn_certificate_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'inn_certificate', command, s3)
@documents_router.get('/ogrn-certificate', response_model=DocumentResponse)
async def get_ogrn_certificate_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'ogrn_certificate', command, s3)
@documents_router.get('/bank-details', response_model=DocumentResponse)
async def get_bank_details_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'bank_details', command, s3)
@documents_router.get('/kyc-representative', response_model=DocumentResponse)
async def get_kyc_representative_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'kyc_representative', command, s3)
@documents_router.get('/power-of-attorney', response_model=DocumentResponse)
async def get_power_of_attorney_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'power_of_attorney', command, s3)
@documents_router.get('/other', response_model=DocumentResponse)
async def get_other_document(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service),
):
return await _get_document(organization_id, 'other', command, s3)
async def _get_document(
organization_id: str,
document_type: str,
command: GetOrganizationDocumentCommand,
s3: S3DocumentsService,
) -> DocumentResponse:
doc = await command(organization_id, document_type)
url = await _document_download_url(s3, doc.s3_key)
doc = await command(organization_id, document_id)
url = await s3.generate_presigned_download_url(key=doc.s3_key)
return document_to_response(doc, download_url=url)

View File

@@ -4,11 +4,7 @@ from src.application.commands import (
CreateOrganizationCommand,
CreateOrganizationWalletsCommand,
GetOrganizationCommand,
GetOrganizationMnemonicCommand,
GetOrganizationSecretKeysCommand,
ListOrganizationWalletsCommand,
ListOrganizationsCommand,
SearchPartiesCommand,
UpdateOrganizationCommand,
)
from src.application.domain.dto import AdminAuthContext
@@ -17,29 +13,14 @@ from src.presentation.dependencies.commands import (
get_create_organization_command,
get_create_organization_wallets_command,
get_get_organization_command,
get_get_organization_mnemonic_command,
get_get_organization_secret_keys_command,
get_list_organization_wallets_command,
get_list_organizations_command,
get_search_parties_command,
get_update_organization_command,
)
from src.presentation.schemas.mappers import (
create_wallets_to_response,
mnemonic_to_response,
organization_to_response,
party_search_to_response,
secret_key_to_response,
wallet_to_response,
)
from src.presentation.schemas.mappers import organization_to_response, wallet_to_response
from src.presentation.schemas.organization import (
CreateOrganizationRequest,
CreateWalletsResponse,
MnemonicResponse,
OrganizationListResponse,
OrganizationResponse,
PartySearchListResponse,
SecretKeyResponse,
UpdateOrganizationRequest,
WalletResponse,
)
@@ -51,11 +32,10 @@ organizations_router = APIRouter(prefix='/organizations', tags=['organizations']
async def list_organizations(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
q: str | None = Query(default=None, min_length=1, max_length=255),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
):
items, total = await command(limit=limit, offset=offset, search=q)
items, total = await command(limit=limit, offset=offset)
return OrganizationListResponse(
items=[organization_to_response(x) for x in items],
total=total,
@@ -87,21 +67,6 @@ async def create_organization(
return organization_to_response(org)
@organizations_router.get('/search', response_model=PartySearchListResponse)
async def search_parties(
q: str = Query(min_length=1, max_length=255),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
auth: AdminAuthContext = Depends(require_admin_access),
command: SearchPartiesCommand = Depends(get_search_parties_command),
):
items, total = await command(query=q, limit=limit, offset=offset)
return PartySearchListResponse(
items=[party_search_to_response(x) for x in items],
total=total,
)
@organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
async def get_organization(
organization_id: str,
@@ -123,47 +88,9 @@ async def update_organization(
return organization_to_response(org)
@organizations_router.get('/{organization_id}/wallets', response_model=list[WalletResponse])
async def list_organization_wallets(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrganizationWalletsCommand = Depends(get_list_organization_wallets_command),
):
wallets = await command(organization_id)
return [wallet_to_response(w) for w in wallets]
@organizations_router.get('/{organization_id}/wallets/mnemonic', response_model=MnemonicResponse)
async def get_organization_mnemonic(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: GetOrganizationMnemonicCommand = Depends(get_get_organization_mnemonic_command),
):
mnemonic = await command(organization_id)
return mnemonic_to_response(mnemonic)
@organizations_router.get('/{organization_id}/wallets/secret-keys', response_model=list[SecretKeyResponse])
async def get_organization_secret_keys(
organization_id: str,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: GetOrganizationSecretKeysCommand = Depends(get_get_organization_secret_keys_command),
):
keys = await command(organization_id)
return [
secret_key_to_response(
chain=k.chain,
address=k.address,
derivation_path=k.derivation_path,
private_key=k.private_key,
)
for k in keys
]
@organizations_router.post(
'/{organization_id}/wallets/create',
response_model=CreateWalletsResponse,
response_model=list[WalletResponse],
status_code=status.HTTP_201_CREATED,
)
async def create_organization_wallets(
@@ -171,5 +98,5 @@ async def create_organization_wallets(
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command),
):
result = await command(organization_id=organization_id)
return create_wallets_to_response(wallets=result.wallets, mnemonic=result.mnemonic)
wallets = await command(organization_id=organization_id)
return [wallet_to_response(w) for w in wallets]

View File

@@ -1,30 +1,24 @@
from fastapi import APIRouter, Depends, Query, status
from src.application.commands import (
CreatePurchaseRequestCommand,
GetPurchaseRequestCommand,
ListPurchaseRequestsCommand,
SetPurchaseRequestQuoteCommand,
UpdatePurchaseRequestCommand,
UpdatePurchaseRequestStatusCommand,
)
from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import (
get_create_purchase_request_command,
get_get_purchase_request_command,
get_list_purchase_requests_command,
get_set_purchase_request_quote_command,
get_update_purchase_request_command,
get_update_purchase_request_status_command,
)
from src.presentation.schemas.mappers import purchase_request_to_response
from src.presentation.schemas.organization import (
CreatePurchaseRequestBody,
PurchaseRequestListResponse,
PurchaseRequestResponse,
SetPurchaseRequestQuoteBody,
UpdatePurchaseRequestBody,
UpdatePurchaseRequestStatusBody,
)
@@ -52,29 +46,6 @@ async def list_purchase_requests(
)
@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED)
async def create_purchase_request(
body: CreatePurchaseRequestBody,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command),
):
item = await command(
organization_id=body.organization_id,
usdt_amount=body.usdt_amount,
status=body.status,
rub_amount=body.rub_amount,
exchange_rate=body.exchange_rate,
service_fee_percent=body.service_fee_percent,
comment=body.comment,
admin_comment=body.admin_comment,
target_wallet_chain=body.target_wallet_chain,
target_wallet_address=body.target_wallet_address,
tx_hash=body.tx_hash,
assigned_to=body.assigned_to,
)
return purchase_request_to_response(item)
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
async def get_purchase_request(
request_id: str,
@@ -85,17 +56,6 @@ async def get_purchase_request(
return purchase_request_to_response(item)
@purchase_requests_router.patch('/{request_id}', response_model=PurchaseRequestResponse)
async def update_purchase_request(
request_id: str,
body: UpdatePurchaseRequestBody,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: UpdatePurchaseRequestCommand = Depends(get_update_purchase_request_command),
):
item = await command(request_id, values=body.model_dump(exclude_unset=True))
return purchase_request_to_response(item)
@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse)
async def update_purchase_request_status(
request_id: str,

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.presentation.dependencies.commands import get_set_password_command
from src.application.commands.set_password import SetPasswordCommand
from presentation.schemas.password import SetPasswordRequest
users_router = APIRouter(prefix='/users', tags=['users'])
@users_router.patch(path='/password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def set_password(
request: Request,
body: SetPasswordRequest,
command: SetPasswordCommand = Depends(get_set_password_command),
):
await command(email=body.email, password=body.password)
return ORJSONResponse(content={'message': 'Password updated successfully'})

View File

@@ -2,19 +2,14 @@ from __future__ import annotations
from src.application.domain.entities.organization import (
LegalEntityEntity,
OrganizationDocumentSlot,
OrganizationDocumentEntity,
OrganizationWalletEntity,
PartySearchEntity,
PurchaseRequestEntity,
)
from src.presentation.schemas.organization import (
CreateWalletsResponse,
DocumentResponse,
MnemonicResponse,
OrganizationResponse,
PartySearchResponse,
PurchaseRequestResponse,
SecretKeyResponse,
WalletResponse,
)
@@ -43,21 +38,6 @@ def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
)
def party_search_to_response(entity: PartySearchEntity) -> PartySearchResponse:
return PartySearchResponse(
id=entity.id,
account_type=entity.account_type,
user_id=entity.user_id,
email=entity.email,
name=entity.name,
inn=entity.inn,
phone=entity.phone,
status=entity.status,
kyc_verified=entity.kyc_verified,
created_at=entity.created_at.isoformat() if entity.created_at else None,
)
def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
return WalletResponse(
id=entity.id,
@@ -68,44 +48,20 @@ def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
)
def create_wallets_to_response(*, wallets: list[OrganizationWalletEntity], mnemonic: str) -> CreateWalletsResponse:
return CreateWalletsResponse(
wallets=[wallet_to_response(w) for w in wallets],
mnemonic=mnemonic,
)
def mnemonic_to_response(mnemonic: str) -> MnemonicResponse:
return MnemonicResponse(mnemonic=mnemonic)
def secret_key_to_response(
*,
chain: str,
address: str,
derivation_path: str,
private_key: str,
) -> SecretKeyResponse:
return SecretKeyResponse(
chain=chain,
address=address,
derivation_path=derivation_path,
private_key=private_key,
)
def document_to_response(
entity: OrganizationDocumentSlot,
entity: OrganizationDocumentEntity,
*,
download_url: str | None = None,
) -> DocumentResponse:
return DocumentResponse(
id=entity.id,
organization_id=entity.organization_id,
document_type=entity.document_type,
s3_key=entity.s3_key,
file_name=entity.file_name,
content_type=entity.content_type,
file_size_bytes=entity.file_size_bytes,
uploaded_by=entity.uploaded_by,
created_at=entity.created_at.isoformat() if entity.created_at else None,
download_url=download_url,
)

View File

@@ -62,24 +62,6 @@ class OrganizationListResponse(BaseModel):
total: int
class PartySearchResponse(BaseModel):
id: str
account_type: str
user_id: str
email: str | None
name: str | None
inn: str | None
phone: str | None
status: str | None
kyc_verified: bool | None
created_at: str | None
class PartySearchListResponse(BaseModel):
items: list[PartySearchResponse]
total: int
class WalletResponse(BaseModel):
id: str
chain: str
@@ -88,47 +70,18 @@ class WalletResponse(BaseModel):
created_at: str | None
class CreateWalletsResponse(BaseModel):
wallets: list[WalletResponse]
mnemonic: str
class MnemonicResponse(BaseModel):
mnemonic: str
class SecretKeyResponse(BaseModel):
chain: str
address: str
derivation_path: str
private_key: str
class DocumentResponse(BaseModel):
id: str
organization_id: str
document_type: str
s3_key: str | None
file_name: str | None
content_type: str | None = None
file_size_bytes: int | None = None
file_name: str
content_type: str
file_size_bytes: int
uploaded_by: str | None
created_at: str | None
download_url: str | None = None
class CreatePurchaseRequestBody(BaseModel):
organization_id: str
usdt_amount: Decimal = Field(gt=0)
status: str = 'submitted'
rub_amount: Decimal | None = Field(default=None, gt=0)
exchange_rate: Decimal | None = Field(default=None, gt=0)
service_fee_percent: Decimal | None = Field(default=None, ge=0)
comment: str | None = None
admin_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)
tx_hash: str | None = Field(default=None, max_length=128)
assigned_to: str | None = None
class UpdatePurchaseRequestStatusBody(BaseModel):
status: str
admin_comment: str | None = None
@@ -136,20 +89,6 @@ class UpdatePurchaseRequestStatusBody(BaseModel):
tx_hash: str | None = None
class UpdatePurchaseRequestBody(BaseModel):
status: str | None = None
usdt_amount: Decimal | None = Field(default=None, gt=0)
rub_amount: Decimal | None = Field(default=None, gt=0)
exchange_rate: Decimal | None = Field(default=None, gt=0)
service_fee_percent: Decimal | None = Field(default=None, ge=0)
comment: str | None = None
admin_comment: str | None = None
target_wallet_chain: str | None = Field(default=None, max_length=16)
target_wallet_address: str | None = Field(default=None, max_length=128)
tx_hash: str | None = Field(default=None, max_length=128)
assigned_to: str | None = None
class SetPurchaseRequestQuoteBody(BaseModel):
rub_amount: Decimal = Field(gt=0)
exchange_rate: Decimal = Field(gt=0)

View File

@@ -0,0 +1,13 @@
import re
from pydantic import BaseModel, field_validator
from src.application.domain.password_policy import validate_password_strength
class SetPasswordRequest(BaseModel):
email: str
password: str
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
return validate_password_strength(v)