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
@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

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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())

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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