diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py index f989e35..16251f1 100644 --- a/src/application/abstractions/repositories/i_legal_entity_repository.py +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -32,7 +32,7 @@ class ILegalEntityRepository(ABC): raise NotImplementedError @abstractmethod - async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: + async def list_all(self, *, limit: int, offset: int, search: str | None = None) -> list[LegalEntityEntity]: raise NotImplementedError @abstractmethod @@ -57,5 +57,5 @@ class ILegalEntityRepository(ABC): raise NotImplementedError @abstractmethod - async def count_all(self) -> int: + async def count_all(self, *, search: str | None = None) -> int: raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index c9c8d79..19c9294 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from datetime import datetime from src.application.domain.entities import UserEntity +from src.application.domain.entities.organization import PartySearchEntity class IUserRepository(ABC): @@ -25,3 +26,11 @@ class IUserRepository(ABC): @abstractmethod async def exists_by_email(self, email: str) -> bool: raise NotImplementedError + + @abstractmethod + async def search_individuals(self, *, query: str, limit: int, offset: int) -> list[PartySearchEntity]: + raise NotImplementedError + + @abstractmethod + async def count_individuals(self, *, query: str) -> int: + raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 3d6ee42..783293c 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -8,6 +8,7 @@ from src.application.commands.put_organization_document import PutOrganizationDo from src.application.commands.organization_commands import ( ListOrganizationsCommand, GetOrganizationCommand, + SearchPartiesCommand, UpdateOrganizationCommand, ) from src.application.commands.organization_document_commands import ( @@ -36,6 +37,7 @@ __all__ = [ 'PutOrganizationDocumentCommand', 'ListOrganizationsCommand', 'GetOrganizationCommand', + 'SearchPartiesCommand', 'UpdateOrganizationCommand', 'ListPurchaseRequestsCommand', 'GetPurchaseRequestCommand', diff --git a/src/application/commands/organization_commands.py b/src/application/commands/organization_commands.py index a7e3d81..c7e4bc8 100644 --- a/src/application/commands/organization_commands.py +++ b/src/application/commands/organization_commands.py @@ -4,7 +4,8 @@ from typing import Any from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger -from src.application.domain.entities.organization import LegalEntityEntity +from src.application.domain.entities.organization import LegalEntityEntity, PartySearchEntity +from src.application.domain.enums.account_type import AccountType from src.infrastructure.database.decorators import transactional @@ -14,12 +15,65 @@ class ListOrganizationsCommand: self._logger = logger @transactional - 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() + 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) 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 diff --git a/src/application/domain/entities/organization.py b/src/application/domain/entities/organization.py index a3962bd..2518084 100644 --- a/src/application/domain/entities/organization.py +++ b/src/application/domain/entities/organization.py @@ -56,6 +56,20 @@ class OrganizationDocumentSlot: 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 + created_at: datetime | None = None + + @dataclass class PurchaseRequestEntity: id: str diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py index d715d7c..01ea461 100644 --- a/src/infrastructure/database/repositories/legal_entity_repository.py +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from sqlalchemy import func, select, update +from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -11,7 +11,7 @@ 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 +from src.infrastructure.database.models import LegalEntityModel, UserModel class LegalEntityRepository(ILegalEntityRepository): @@ -49,6 +49,37 @@ class LegalEntityRepository(ILegalEntityRepository): 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, *, @@ -102,14 +133,37 @@ 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) -> list[LegalEntityEntity]: - res = await self._session.execute( - select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset) + 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) ) + 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) -> int: - res = await self._session.execute(select(func.count()).select_from(LegalEntityModel)) + 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) return int(res.scalar_one()) async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity: diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index 0d694c2..4be5aa2 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -3,13 +3,14 @@ from __future__ import annotations from datetime import datetime from fastapi import status -from sqlalchemy import select +from sqlalchemy import func, or_, 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.infrastructure.database.models import UserModel @@ -92,3 +93,69 @@ 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()) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index dbfab80..99fb402 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -19,6 +19,7 @@ from src.application.commands import ( GetOrganizationDocumentCommand, ListOrganizationDocumentsCommand, ListPurchaseRequestsCommand, + SearchPartiesCommand, SetPurchaseRequestQuoteCommand, UpdateOrganizationCommand, UpdatePurchaseRequestStatusCommand, @@ -129,6 +130,13 @@ def get_list_organizations_command( return ListOrganizationsCommand(uow, logger) +def get_search_parties_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> SearchPartiesCommand: + return SearchPartiesCommand(uow, logger) + + def get_get_organization_command( uow: IUnitOfWork = Depends(get_unit_of_work), logger: ILogger = Depends(get_logger), diff --git a/src/presentation/routing/organizations.py b/src/presentation/routing/organizations.py index 4cd2549..18ddfba 100644 --- a/src/presentation/routing/organizations.py +++ b/src/presentation/routing/organizations.py @@ -8,6 +8,7 @@ from src.application.commands import ( GetOrganizationSecretKeysCommand, ListOrganizationWalletsCommand, ListOrganizationsCommand, + SearchPartiesCommand, UpdateOrganizationCommand, ) from src.application.domain.dto import AdminAuthContext @@ -20,12 +21,14 @@ from src.presentation.dependencies.commands import ( 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, ) @@ -35,6 +38,7 @@ from src.presentation.schemas.organization import ( MnemonicResponse, OrganizationListResponse, OrganizationResponse, + PartySearchListResponse, SecretKeyResponse, UpdateOrganizationRequest, WalletResponse, @@ -47,10 +51,11 @@ 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) + items, total = await command(limit=limit, offset=offset, search=q) return OrganizationListResponse( items=[organization_to_response(x) for x in items], total=total, @@ -82,6 +87,21 @@ 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, diff --git a/src/presentation/schemas/mappers.py b/src/presentation/schemas/mappers.py index c240bfe..ae3d8b3 100644 --- a/src/presentation/schemas/mappers.py +++ b/src/presentation/schemas/mappers.py @@ -4,6 +4,7 @@ from src.application.domain.entities.organization import ( LegalEntityEntity, OrganizationDocumentSlot, OrganizationWalletEntity, + PartySearchEntity, PurchaseRequestEntity, ) from src.presentation.schemas.organization import ( @@ -11,6 +12,7 @@ from src.presentation.schemas.organization import ( DocumentResponse, MnemonicResponse, OrganizationResponse, + PartySearchResponse, PurchaseRequestResponse, SecretKeyResponse, WalletResponse, @@ -41,6 +43,21 @@ 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, diff --git a/src/presentation/schemas/organization.py b/src/presentation/schemas/organization.py index cdec1b0..3c327a5 100644 --- a/src/presentation/schemas/organization.py +++ b/src/presentation/schemas/organization.py @@ -62,6 +62,24 @@ 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