feat: add search

This commit is contained in:
2026-06-09 20:24:25 +03:00
parent cc75f61e76
commit f9dc59729c
11 changed files with 278 additions and 15 deletions

View File

@@ -32,7 +32,7 @@ class ILegalEntityRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @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 raise NotImplementedError
@abstractmethod @abstractmethod
@@ -57,5 +57,5 @@ class ILegalEntityRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def count_all(self) -> int: async def count_all(self, *, search: str | None = None) -> int:
raise NotImplementedError raise NotImplementedError

View File

@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.application.domain.entities.organization import PartySearchEntity
class IUserRepository(ABC): class IUserRepository(ABC):
@@ -25,3 +26,11 @@ class IUserRepository(ABC):
@abstractmethod @abstractmethod
async def exists_by_email(self, email: str) -> bool: async def exists_by_email(self, email: str) -> bool:
raise NotImplementedError 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

View File

@@ -8,6 +8,7 @@ from src.application.commands.put_organization_document import PutOrganizationDo
from src.application.commands.organization_commands import ( from src.application.commands.organization_commands import (
ListOrganizationsCommand, ListOrganizationsCommand,
GetOrganizationCommand, GetOrganizationCommand,
SearchPartiesCommand,
UpdateOrganizationCommand, UpdateOrganizationCommand,
) )
from src.application.commands.organization_document_commands import ( from src.application.commands.organization_document_commands import (
@@ -36,6 +37,7 @@ __all__ = [
'PutOrganizationDocumentCommand', 'PutOrganizationDocumentCommand',
'ListOrganizationsCommand', 'ListOrganizationsCommand',
'GetOrganizationCommand', 'GetOrganizationCommand',
'SearchPartiesCommand',
'UpdateOrganizationCommand', 'UpdateOrganizationCommand',
'ListPurchaseRequestsCommand', 'ListPurchaseRequestsCommand',
'GetPurchaseRequestCommand', 'GetPurchaseRequestCommand',

View File

@@ -4,7 +4,8 @@ from typing import Any
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger 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 from src.infrastructure.database.decorators import transactional
@@ -14,12 +15,65 @@ class ListOrganizationsCommand:
self._logger = logger self._logger = logger
@transactional @transactional
async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]: async def __call__(
items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset) self,
total = await self._unit_of_work.legal_entity_repository.count_all() *,
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 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: class GetOrganizationCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work

View File

@@ -56,6 +56,20 @@ class OrganizationDocumentSlot:
file_size_bytes: int | 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
created_at: datetime | None = None
@dataclass @dataclass
class PurchaseRequestEntity: class PurchaseRequestEntity:
id: str id: str

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any 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.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError 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.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.application.domain.organization_documents import DOCUMENT_TYPE_TO_COLUMN, ORGANIZATION_DOCUMENT_TYPES 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): class LegalEntityRepository(ILegalEntityRepository):
@@ -49,6 +49,37 @@ class LegalEntityRepository(ILegalEntityRepository):
updated_at=m.updated_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( async def create(
self, self,
*, *,
@@ -102,14 +133,37 @@ class LegalEntityRepository(ILegalEntityRepository):
raise ApplicationException(status_code=404, message='Organization not found') raise ApplicationException(status_code=404, message='Organization not found')
return self._to_entity(m) return self._to_entity(m)
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: async def list_all(
res = await self._session.execute( self,
select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset) *,
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()] return [self._to_entity(m) for m in res.scalars().all()]
async def count_all(self) -> int: async def count_all(self, *, search: str | None = None) -> int:
res = await self._session.execute(select(func.count()).select_from(LegalEntityModel)) 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()) return int(res.scalar_one())
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity: async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:

View File

@@ -3,13 +3,14 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from fastapi import status from fastapi import status
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IUserRepository from src.application.abstractions.repositories import IUserRepository
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.entities import UserEntity 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.enums.account_type import AccountType
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import UserModel 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) stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1)
result = await self._session.execute(stmt) result = await self._session.execute(stmt)
return result.scalar_one_or_none() is not None 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

@@ -19,6 +19,7 @@ from src.application.commands import (
GetOrganizationDocumentCommand, GetOrganizationDocumentCommand,
ListOrganizationDocumentsCommand, ListOrganizationDocumentsCommand,
ListPurchaseRequestsCommand, ListPurchaseRequestsCommand,
SearchPartiesCommand,
SetPurchaseRequestQuoteCommand, SetPurchaseRequestQuoteCommand,
UpdateOrganizationCommand, UpdateOrganizationCommand,
UpdatePurchaseRequestStatusCommand, UpdatePurchaseRequestStatusCommand,
@@ -129,6 +130,13 @@ def get_list_organizations_command(
return ListOrganizationsCommand(uow, logger) 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( def get_get_organization_command(
uow: IUnitOfWork = Depends(get_unit_of_work), uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),

View File

@@ -8,6 +8,7 @@ from src.application.commands import (
GetOrganizationSecretKeysCommand, GetOrganizationSecretKeysCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
ListOrganizationsCommand, ListOrganizationsCommand,
SearchPartiesCommand,
UpdateOrganizationCommand, UpdateOrganizationCommand,
) )
from src.application.domain.dto import AdminAuthContext from src.application.domain.dto import AdminAuthContext
@@ -20,12 +21,14 @@ from src.presentation.dependencies.commands import (
get_get_organization_secret_keys_command, get_get_organization_secret_keys_command,
get_list_organization_wallets_command, get_list_organization_wallets_command,
get_list_organizations_command, get_list_organizations_command,
get_search_parties_command,
get_update_organization_command, get_update_organization_command,
) )
from src.presentation.schemas.mappers import ( from src.presentation.schemas.mappers import (
create_wallets_to_response, create_wallets_to_response,
mnemonic_to_response, mnemonic_to_response,
organization_to_response, organization_to_response,
party_search_to_response,
secret_key_to_response, secret_key_to_response,
wallet_to_response, wallet_to_response,
) )
@@ -35,6 +38,7 @@ from src.presentation.schemas.organization import (
MnemonicResponse, MnemonicResponse,
OrganizationListResponse, OrganizationListResponse,
OrganizationResponse, OrganizationResponse,
PartySearchListResponse,
SecretKeyResponse, SecretKeyResponse,
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse, WalletResponse,
@@ -47,10 +51,11 @@ organizations_router = APIRouter(prefix='/organizations', tags=['organizations']
async def list_organizations( async def list_organizations(
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), 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), auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrganizationsCommand = Depends(get_list_organizations_command), 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( return OrganizationListResponse(
items=[organization_to_response(x) for x in items], items=[organization_to_response(x) for x in items],
total=total, total=total,
@@ -82,6 +87,21 @@ async def create_organization(
return organization_to_response(org) 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) @organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
async def get_organization( async def get_organization(
organization_id: str, organization_id: str,

View File

@@ -4,6 +4,7 @@ from src.application.domain.entities.organization import (
LegalEntityEntity, LegalEntityEntity,
OrganizationDocumentSlot, OrganizationDocumentSlot,
OrganizationWalletEntity, OrganizationWalletEntity,
PartySearchEntity,
PurchaseRequestEntity, PurchaseRequestEntity,
) )
from src.presentation.schemas.organization import ( from src.presentation.schemas.organization import (
@@ -11,6 +12,7 @@ from src.presentation.schemas.organization import (
DocumentResponse, DocumentResponse,
MnemonicResponse, MnemonicResponse,
OrganizationResponse, OrganizationResponse,
PartySearchResponse,
PurchaseRequestResponse, PurchaseRequestResponse,
SecretKeyResponse, SecretKeyResponse,
WalletResponse, 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: def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
return WalletResponse( return WalletResponse(
id=entity.id, id=entity.id,

View File

@@ -62,6 +62,24 @@ class OrganizationListResponse(BaseModel):
total: int 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): class WalletResponse(BaseModel):
id: str id: str
chain: str chain: str