From 4aae631c73e08e92ef1d15b98647e86e521876fb Mon Sep 17 00:00:00 2001 From: dev1lfreak Date: Thu, 11 Jun 2026 18:46:21 +0300 Subject: [PATCH] feat: add user endpoints --- .../abstractions/i_unit_of_work.py | 4 + .../abstractions/repositories/__init__.py | 1 + .../repositories/i_user_repository.py | 12 ++ .../repositories/i_wallets_repository.py | 14 +++ src/application/commands/__init__.py | 14 +++ src/application/commands/set_password.py | 6 +- src/application/commands/user_commands.py | 42 +++++++ .../commands/user_wallet_commands.py | 63 ++++++++++ src/application/domain/entities/user.py | 9 ++ .../database/models/__init__.py | 2 + src/infrastructure/database/models/wallets.py | 28 +++++ .../database/repositories/__init__.py | 2 + .../database/repositories/user_repository.py | 61 +++++++++- .../repositories/wallets_repository.py | 43 +++++++ src/infrastructure/database/unit_of_work.py | 11 ++ src/presentation/dependencies/commands.py | 35 ++++++ src/presentation/routing/organizations.py | 1 - src/presentation/routing/users.py | 109 +++++++++++++++++- src/presentation/schemas/mappers.py | 28 ++++- src/presentation/schemas/password.py | 1 - src/presentation/schemas/user.py | 32 +++++ 21 files changed, 503 insertions(+), 15 deletions(-) create mode 100644 src/application/abstractions/repositories/i_wallets_repository.py create mode 100644 src/application/commands/user_commands.py create mode 100644 src/application/commands/user_wallet_commands.py create mode 100644 src/infrastructure/database/models/wallets.py create mode 100644 src/infrastructure/database/repositories/wallets_repository.py create mode 100644 src/presentation/schemas/user.py diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py index 334b4a4..8d0654b 100644 --- a/src/application/abstractions/i_unit_of_work.py +++ b/src/application/abstractions/i_unit_of_work.py @@ -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: ... \ No newline at end of file diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py index 7f4d3da..de4af85 100644 --- a/src/application/abstractions/repositories/__init__.py +++ b/src/application/abstractions/repositories/__init__.py @@ -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 diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index 76ab23b..3763ea3 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -18,6 +18,14 @@ class IUserRepository(ABC): kyc_verified_at: datetime, ) -> 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: @@ -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 diff --git a/src/application/abstractions/repositories/i_wallets_repository.py b/src/application/abstractions/repositories/i_wallets_repository.py new file mode 100644 index 0000000..f0c253b --- /dev/null +++ b/src/application/abstractions/repositories/i_wallets_repository.py @@ -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 \ No newline at end of file diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 976dcc1..c41eda8 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -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', ] diff --git a/src/application/commands/set_password.py b/src/application/commands/set_password.py index 8e7525c..af960db 100644 --- a/src/application/commands/set_password.py +++ b/src/application/commands/set_password.py @@ -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 diff --git a/src/application/commands/user_commands.py b/src/application/commands/user_commands.py new file mode 100644 index 0000000..1ccbfea --- /dev/null +++ b/src/application/commands/user_commands.py @@ -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) diff --git a/src/application/commands/user_wallet_commands.py b/src/application/commands/user_wallet_commands.py new file mode 100644 index 0000000..242cbdf --- /dev/null +++ b/src/application/commands/user_wallet_commands.py @@ -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 \ No newline at end of file diff --git a/src/application/domain/entities/user.py b/src/application/domain/entities/user.py index 81457a4..d237d4e 100644 --- a/src/application/domain/entities/user.py +++ b/src/application/domain/entities/user.py @@ -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 diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py index 727a605..ef7b775 100644 --- a/src/infrastructure/database/models/__init__.py +++ b/src/infrastructure/database/models/__init__.py @@ -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', ] diff --git a/src/infrastructure/database/models/wallets.py b/src/infrastructure/database/models/wallets.py new file mode 100644 index 0000000..bb211d4 --- /dev/null +++ b/src/infrastructure/database/models/wallets.py @@ -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(), + ) \ No newline at end of file diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py index edef283..3940d8c 100644 --- a/src/infrastructure/database/repositories/__init__.py +++ b/src/infrastructure/database/repositories/__init__.py @@ -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', ] diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index 176b3e2..69d3a16 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -45,6 +45,16 @@ class UserRepository(IUserRepository): provisioned_by=user.provisioned_by, 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: @@ -126,6 +135,41 @@ 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 _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()}%' @@ -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()) diff --git a/src/infrastructure/database/repositories/wallets_repository.py b/src/infrastructure/database/repositories/wallets_repository.py new file mode 100644 index 0000000..6941908 --- /dev/null +++ b/src/infrastructure/database/repositories/wallets_repository.py @@ -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 diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py index 31cd1cc..2049dae 100644 --- a/src/infrastructure/database/unit_of_work.py +++ b/src/infrastructure/database/unit_of_work.py @@ -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): @@ -84,6 +87,14 @@ class UnitOfWork(IUnitOfWork): session=self._session, logger=self._logger ) 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: diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index b5efc20..43d8188 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -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) \ No newline at end of file diff --git a/src/presentation/routing/organizations.py b/src/presentation/routing/organizations.py index 275c7f9..dda18db 100644 --- a/src/presentation/routing/organizations.py +++ b/src/presentation/routing/organizations.py @@ -42,7 +42,6 @@ from src.presentation.schemas.organization import ( from src.presentation.schemas.entity import ( CreateWalletsResponse, MnemonicResponse, - OrganizationResponse, PartySearchResponse, PartySearchListResponse, PurchaseRequestResponse, diff --git a/src/presentation/routing/users.py b/src/presentation/routing/users.py index 88010e0..a68e60a 100644 --- a/src/presentation/routing/users.py +++ b/src/presentation/routing/users.py @@ -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( - request: Request, + 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'}) \ No newline at end of file diff --git a/src/presentation/schemas/mappers.py b/src/presentation/schemas/mappers.py index 9cfdb43..9fee498 100644 --- a/src/presentation/schemas/mappers.py +++ b/src/presentation/schemas/mappers.py @@ -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, + ) \ No newline at end of file diff --git a/src/presentation/schemas/password.py b/src/presentation/schemas/password.py index 5f001ec..bf0be5b 100644 --- a/src/presentation/schemas/password.py +++ b/src/presentation/schemas/password.py @@ -4,7 +4,6 @@ from src.application.domain.password_policy import validate_password_strength class SetPasswordRequest(BaseModel): - email: str password: str @field_validator('password') diff --git a/src/presentation/schemas/user.py b/src/presentation/schemas/user.py new file mode 100644 index 0000000..b6d28a4 --- /dev/null +++ b/src/presentation/schemas/user.py @@ -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