Функционал для физ лиц #2

Merged
skvachuk merged 2 commits from admin_dev into main 2026-06-11 15:58:39 +00:00
21 changed files with 503 additions and 15 deletions
Showing only changes of commit 4aae631c73 - Show all commits

View File

@@ -9,6 +9,7 @@ from src.application.abstractions.repositories import (
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
IWalletRepository,
)
@@ -37,3 +38,6 @@ class IUnitOfWork(Protocol):
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
@property
def wallet_repository(self) -> IWalletRepository: ...

View File

@@ -4,3 +4,4 @@ from src.application.abstractions.repositories.i_admin_session_repository import
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_purchase_request_repository import IPurchaseRequestRepository
from src.application.abstractions.repositories.i_wallets_repository import IWalletRepository

View File

@@ -19,6 +19,14 @@ class IUserRepository(ABC):
) -> UserEntity:
raise NotImplementedError
@abstractmethod
async def list_all(self, *, limit: int, offset: int, search: str | None = None) -> list[UserEntity]:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, user_id: str) -> UserEntity:
raise NotImplementedError
@abstractmethod
async def get_user_by_email(self, email: str) -> UserEntity:
raise NotImplementedError
@@ -35,6 +43,10 @@ class IUserRepository(ABC):
async def count_individuals(self, *, query: str) -> int:
raise NotImplementedError
@abstractmethod
async def count_all(self, *, search: str) -> int:
raise NotImplementedError
@abstractmethod
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
raise NotImplementedError

View File

@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.user import WalletEntity
class IWalletRepository(ABC):
@abstractmethod
async def list_by_user(self, user_id: str) -> list[WalletEntity]:
raise NotImplementedError
@abstractmethod
async def exists_for_user(self, user_id: str) -> bool:
raise NotImplementedError

View File

@@ -29,6 +29,15 @@ from src.application.commands.purchase_request_commands import (
UpdatePurchaseRequestStatusCommand,
SetPurchaseRequestQuoteCommand,
)
from src.application.commands.user_commands import (
ListUsersCommand,
GetUserCommand,
)
from src.application.commands.user_wallet_commands import (
GetUserMnemonicCommand,
GetUserSecretKeysCommand,
ListUserWalletsCommand,
)
__all__ = [
'AdminLoginCommand',
@@ -54,4 +63,9 @@ __all__ = [
'GetOrganizationMnemonicCommand',
'GetOrganizationSecretKeysCommand',
'SetPasswordCommand',
'ListUsersCommand',
'GetUserCommand',
'ListUserWalletsCommand',
'GetUserMnemonicCommand',
'GetUserSecretKeysCommand',
]

View File

@@ -16,14 +16,14 @@ class SetPasswordCommand:
self._logger = logger
@transactional
async def __call__(self, email: str, password: str) -> bool:
async def __call__(self, user_id: str, password: str) -> bool:
try:
password_hash = await self._hash_service.hash(password)
await self._unit_of_work.user_repository.set_password(
user_email=email,
user_id=user_id,
password_hash=password_hash,
)
self._logger.info(f'Set password for user {email}')
self._logger.info(f'Set password for user {user_id}')
return True
except ApplicationException:
raise

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import PartySearchEntity
from src.application.domain.entities.user import UserEntity
from src.application.domain.enums.account_type import AccountType
from src.infrastructure.database.decorators import transactional
class ListUsersCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(
self,
*,
limit: int = 50,
offset: int = 0,
search: str | None = None,
) -> tuple[list[UserEntity], int]:
items = await self._unit_of_work.user_repository.list_all(
limit=limit,
offset=offset,
search=search,
)
total = await self._unit_of_work.user_repository.count_all(search=search)
return items, total
class GetUserCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, user_id: str) -> UserEntity:
return await self._unit_of_work.user_repository.get_by_id(user_id)

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.user import UserEntity, WalletEntity
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 ListUserWalletsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, user_id: str) -> list[WalletEntity]:
await self._unit_of_work.user_repository.get_by_id(user_id)
return await self._unit_of_work.wallet_repository.list_by_user(user_id)
class GetUserMnemonicCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, user_id: str) -> str:
user = await self._require_wallets_user(user_id)
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
return decrypt_mnemonic(user.encrypted_mnemonic or '')
async def _require_wallets_user(self, user_id: str) -> UserEntity:
user = await self._unit_of_work.user_repository.get_by_id(user_id)
if not user.encrypted_mnemonic:
raise ApplicationException(status_code=404, message='Wallets not created for user')
return user
class GetUserSecretKeysCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, user_id: str) -> list[DerivedSecretKey]:
user = await self._require_wallets_user(user_id)
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
mnemonic = decrypt_mnemonic(user.encrypted_mnemonic or '')
return derive_all_private_keys(mnemonic)
async def _require_wallets_user(self, user_id: str) -> UserEntity:
user = await self._unit_of_work.user_repository.get_by_id(user_id)
if not user.encrypted_mnemonic:
raise ApplicationException(status_code=404, message='Wallets not created for user')
return user

View File

@@ -32,3 +32,12 @@ class UserEntity:
account_type: str | None = None
provisioned_by: str | None = None
provisioned_at: datetime | None = None
@dataclass
class WalletEntity:
id: str
user_id: str
chain: str
address: str
derivation_path: str
created_at: datetime | None = None

View File

@@ -5,6 +5,7 @@ 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.purchase_request import PurchaseRequestModel
from src.infrastructure.database.models.wallets import WalletModel
__all__ = [
'Base',
@@ -14,4 +15,5 @@ __all__ = [
'LegalEntityModel',
'OrganizationWalletModel',
'PurchaseRequestModel',
'WalletModel',
]

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import 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 WalletModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'wallets'
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
chain: Mapped[str] = mapped_column(String(16), nullable=False)
address: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
derivation_path: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -4,6 +4,7 @@ from src.infrastructure.database.repositories.user_repository import UserReposit
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.purchase_request_repository import PurchaseRequestRepository
from src.infrastructure.database.repositories.wallets_repository import WalletRepository
__all__ = [
'AdminUserRepository',
@@ -12,4 +13,5 @@ __all__ = [
'LegalEntityRepository',
'OrganizationWalletRepository',
'PurchaseRequestRepository',
'WalletRepository',
]

View File

@@ -46,6 +46,16 @@ class UserRepository(IUserRepository):
provisioned_at=user.provisioned_at,
)
async def get_by_id(self, user_id: str) -> UserEntity:
try:
user = await self._get_user(user_id)
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 create_legal_entity_user(
self,
*,
@@ -104,9 +114,8 @@ class UserRepository(IUserRepository):
self._logger.exception(str(exception))
raise InternalServerException(message=f'Database error: {str(exception)}')
async def set_password(self, user_email: str, password_hash: str) -> UserEntity:
user = await self.get_user_by_email(user_email)
return await self._update_field(user.id, password_hash=password_hash)
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:
@@ -127,6 +136,41 @@ class UserRepository(IUserRepository):
result = await self._session.execute(stmt)
return result.scalar_one_or_none() is not None
def _search_filter(self, search: str | None):
if not search or not search.strip():
return None
pattern = f'%{search.strip()}%'
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.inn.ilike(pattern),
)
async def list_all(
self,
*,
limit: int,
offset: int,
search: str | None = None,
) -> list[UserEntity]:
stmt = (
select(UserModel)
.where(UserModel.is_deleted.is_(False))
.order_by(UserModel.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()]
def _individual_search_filter(self, query: str):
pattern = f'%{query.strip()}%'
full_name = func.concat_ws(
@@ -192,3 +236,14 @@ class UserRepository(IUserRepository):
)
result = await self._session.execute(stmt)
return int(result.scalar_one())
async def count_all(self, *, search: str | None = None) -> int:
stmt = (
select(func.count(UserModel.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)
return int(res.scalar_one())

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IWalletRepository
from src.application.contracts import ILogger
from src.application.domain.entities.user import WalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import WalletModel
class WalletRepository(IWalletRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: WalletModel) -> WalletEntity:
return WalletEntity(
id=m.id,
user_id=m.user_id,
chain=m.chain,
address=m.address,
derivation_path=m.derivation_path,
created_at=m.created_at,
)
async def list_by_user(self, user_id: str) -> list[WalletEntity]:
res = await self._session.execute(
select(WalletModel)
.where(WalletModel.user_id == user_id)
.order_by(WalletModel.chain)
)
return [self._to_entity(m) for m in res.scalars().all()]
async def exists_for_user(self, user_id: str) -> bool:
res = await self._session.execute(
select(WalletModel.id)
.where(WalletModel.user_id == user_id)
.limit(1)
)
return res.scalar_one_or_none() is not None

View File

@@ -8,6 +8,7 @@ from src.application.abstractions.repositories import (
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
IWalletRepository,
)
from src.application.contracts import ILogger
from src.application.domain.exceptions import RefreshConcurrentException
@@ -18,6 +19,7 @@ from src.infrastructure.database.repositories import (
OrganizationWalletRepository,
PurchaseRequestRepository,
UserRepository,
WalletRepository,
)
@@ -31,6 +33,7 @@ class UnitOfWork(IUnitOfWork):
self._legal_entity_repository: ILegalEntityRepository | None = None
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
self._purchase_request_repository: IPurchaseRequestRepository | None = None
self._wallet_repository: IWalletRepository | None = None
self._logger: ILogger = logger
async def __aenter__(self):
@@ -85,6 +88,14 @@ class UnitOfWork(IUnitOfWork):
)
return self._organization_wallet_repository
@property
def wallet_repository(self) -> IWalletRepository:
if self._wallet_repository is None:
self._wallet_repository = WalletRepository(
session=self._session, logger=self._logger
)
return self._wallet_repository
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository:
if self._purchase_request_repository is None:

View File

@@ -27,6 +27,11 @@ from src.application.commands import (
UpdatePurchaseRequestStatusCommand,
PutOrganizationDocumentCommand,
SetPasswordCommand,
ListUsersCommand,
GetUserCommand,
ListUserWalletsCommand,
GetUserMnemonicCommand,
GetUserSecretKeysCommand,
)
from src.application.contracts import IHashService, IJwtService, ILogger
from src.infrastructure.config import settings
@@ -215,3 +220,33 @@ def get_set_password_command(
logger: ILogger = Depends(get_logger),
) -> SetPasswordCommand:
return SetPasswordCommand(uow, hash_service, logger)
def get_list_users_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> ListUsersCommand:
return ListUsersCommand(uow, logger)
def get_get_user_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetUserCommand:
return GetUserCommand(uow, logger)
def get_list_user_wallets_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> ListUserWalletsCommand:
return ListUserWalletsCommand(uow, logger)
def get_get_user_mnemonic_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetUserMnemonicCommand:
return GetUserMnemonicCommand(uow, logger)
def get_get_user_secret_keys_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger),
) -> GetUserSecretKeysCommand:
return GetUserSecretKeysCommand(uow, logger)

View File

@@ -42,7 +42,6 @@ from src.presentation.schemas.organization import (
from src.presentation.schemas.entity import (
CreateWalletsResponse,
MnemonicResponse,
OrganizationResponse,
PartySearchResponse,
PartySearchListResponse,
PurchaseRequestResponse,

View File

@@ -1,17 +1,116 @@
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Query, status
from fastapi.responses import ORJSONResponse
from starlette import status
from src.presentation.dependencies.commands import get_set_password_command
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_set_password_command,
get_list_users_command,
get_get_user_command,
get_list_user_wallets_command,
get_get_user_mnemonic_command,
get_get_user_secret_keys_command,
)
from src.presentation.schemas.mappers import (
mnemonic_to_response,
user_to_response,
party_search_to_response,
secret_key_to_response,
wallet_to_response,
)
from src.presentation.schemas.user import (
UserResponse,
UserListResponse,
)
from src.presentation.schemas.entity import (
PartySearchResponse,
MnemonicResponse,
PartySearchListResponse,
SecretKeyResponse,
WalletResponse,
)
from src.application.commands.set_password import SetPasswordCommand
from src.application.commands.user_commands import (
ListUsersCommand,
GetUserCommand,
)
from src.application.commands.user_wallet_commands import (
ListUserWalletsCommand,
GetUserMnemonicCommand,
GetUserSecretKeysCommand
)
from src.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)
@users_router.get('', response_model=UserListResponse)
async def list_users(
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: ListUsersCommand = Depends(get_list_users_command),
):
items, total = await command(limit=limit, offset=offset, search=q)
return UserListResponse(
items=[user_to_response(x) for x in items],
total=total,
)
@users_router.get('/{user_id}', response_model=UserResponse)
async def get_user(
user_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: GetUserCommand = Depends(get_get_user_command),
):
user = await command(user_id)
return user_to_response(user)
@users_router.get('/{user_id}/wallets', response_model=list[WalletResponse])
async def list_user_wallets(
user_id: str,
auth: AdminAuthContext = Depends(require_admin_access),
command: ListUserWalletsCommand = Depends(get_list_user_wallets_command),
):
wallets = await command(user_id)
return [wallet_to_response(w) for w in wallets]
@users_router.get('/{user_id}/wallets/mnemonic', response_model=MnemonicResponse)
async def get_user_mnemonic(
user_id: str,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: GetUserMnemonicCommand = Depends(get_get_user_mnemonic_command),
):
mnemonic = await command(user_id)
return mnemonic_to_response(mnemonic)
@users_router.get('/{user_id}/wallets/secret-keys', response_model=list[SecretKeyResponse])
async def get_user_secret_keys(
user_id: str,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: GetUserSecretKeysCommand = Depends(get_get_user_secret_keys_command),
):
keys = await command(user_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
]
@users_router.patch(path='/{user_id}/set_password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def set_password(
user_id: str,
request: Request,
body: SetPasswordRequest,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
command: SetPasswordCommand = Depends(get_set_password_command),
):
await command(email=body.email, password=body.password)
await command(user_id=user_id, password=body.password)
return ORJSONResponse(content={'message': 'Password updated successfully'})

View File

@@ -7,15 +7,19 @@ from src.application.domain.entities.organization import (
PartySearchEntity,
PurchaseRequestEntity,
)
from src.application.domain.entities.user import (
UserEntity,
)
from src.presentation.schemas.user import (
UserResponse,
)
from src.presentation.schemas.organization import (
DocumentResponse,
OrganizationResponse,
)
from src.presentation.schemas.entity import (
CreateWalletsResponse,
DocumentResponse,
MnemonicResponse,
OrganizationResponse,
PartySearchResponse,
PurchaseRequestResponse,
SecretKeyResponse,
@@ -133,3 +137,23 @@ def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseReque
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
)
def user_to_response(entity: UserEntity) -> UserResponse:
return UserResponse(
id=entity.id,
email=entity.email,
first_name=entity.first_name,
middle_name=entity.middle_name,
last_name=entity.last_name,
birth_date=entity.birth_date,
phone=entity.phone,
passport_data=entity.passport_data,
inn=entity.inn,
avatar_link=entity.avatar_link,
account_type=entity.account_type,
kyc_verified=entity.kyc_verified,
is_deleted=entity.is_deleted,
created_at=entity.created_at.isoformat() if entity.created_at else None,
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None,
)

View File

@@ -4,7 +4,6 @@ from src.application.domain.password_policy import validate_password_strength
class SetPasswordRequest(BaseModel):
email: str
password: str
@field_validator('password')

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from datetime import date, datetime
from pydantic import BaseModel, Field
class UserResponse(BaseModel):
id: str | None = Field(None, description='Идентификатор пользователя')
email: str | None = Field(None, description='Email')
first_name: str | None = Field(None, description='Имя')
middle_name: str | None = Field(None, description='Отчество')
last_name: str | None = Field(None, description='Фамилия')
birth_date: date | None = Field(None, description='Дата рождения')
phone: str | None = Field(None, description='Телефон')
passport_data: str | None = Field(None, description='Паспортные данные')
inn: str | None = Field(None, description='ИНН')
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
account_type: str | None = Field(None, description='Тип аккаунта')
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
created_at: datetime | None = Field(None, description='Время создания записи')
updated_at: datetime | None = Field(None, description='Время последнего обновления')
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int