feat: add search
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user