1 Commits

Author SHA1 Message Date
f6ffe68e6a feat: update for b2b 2026-06-02 23:46:57 +03:00
19 changed files with 220 additions and 67 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository
@runtime_checkable
@@ -17,3 +17,6 @@ class IUnitOfWork(Protocol):
@property
def session_repository(self) -> ISessionRepository: ...
@property
def legal_entity_repository(self) -> ILegalEntityRepository: ...

View File

@@ -1,2 +1,3 @@
from src.application.abstractions.repositories.i_user_repository import IUserRepository
from src.application.abstractions.repositories.i_session_repository import ISessionRepository
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository

View File

@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.legal_entity import LegalEntityEntity
class ILegalEntityRepository(ABC):
@abstractmethod
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
raise NotImplementedError

View File

@@ -1,6 +1,7 @@
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger, ICache
from src.application.domain.entities import UserEntity
from src.application.domain.enums.account_type import AccountType
from src.infrastructure.database.decorators import transactional
@@ -13,5 +14,7 @@ class GetMeCommand:
@transactional
async def __call__(self, user_id: str) -> UserEntity:
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
if user.account_type == AccountType.LEGAL_ENTITY.value:
user.legal_entity = await self._unit_of_work.legal_entity_repository.get_by_user_id(user_id)
self._logger.info(f'User ID: {user.id}')
return user

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@dataclass(slots=True)
class LegalEntityEntity:
id: str
user_id: str
name: str
inn: str
status: str
short_name: str | None = None
ogrn: str | None = None
kpp: str | None = None
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict[str, Any] | None = None
contact_person: str | None = None
contact_phone: str | None = None
kyc_verified: bool = True
kyc_verified_at: datetime | None = None

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from src.application.domain.entities.legal_entity import LegalEntityEntity
@dataclass(slots=True)
class UserEntity:
@@ -28,3 +30,6 @@ class UserEntity:
created_at: datetime | None = None
updated_at: datetime | None = None
kyc_verified_at: datetime | None = None
account_type: str = 'individual'
legal_entity: LegalEntityEntity | None = None

View File

@@ -0,0 +1,6 @@
from enum import StrEnum
class AccountType(StrEnum):
INDIVIDUAL = 'individual'
LEGAL_ENTITY = 'legal_entity'

View File

@@ -1,6 +1,7 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.sessions import Session
__all__ = ['Base', 'UserModel', 'Session']
__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'Session']

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'legal_entities'
user_id: Mapped[str] = mapped_column(String(26), ForeignKey('users.id', ondelete='RESTRICT'), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(512), nullable=False)
short_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True)
ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True)
kpp: Mapped[str | None] = mapped_column(String(9), nullable=True)
legal_address: Mapped[str | None] = mapped_column(Text, nullable=True)
actual_address: Mapped[str | None] = mapped_column(Text, nullable=True)
bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active')
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(26), nullable=True)

View File

@@ -27,3 +27,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual')
provisioned_by: Mapped[str | None] = mapped_column(String(26), nullable=True)
provisioned_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -1,2 +1,3 @@
from src.infrastructure.database.repositories.user_repository import UserRepository
from src.infrastructure.database.repositories.session_repository import SessionRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
from src.application.contracts import ILogger
from src.application.domain.entities.legal_entity import LegalEntityEntity
from src.application.domain.exceptions import InternalException
from src.infrastructure.database.models.legal_entity import LegalEntityModel
class LegalEntityRepository(ILegalEntityRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
@staticmethod
def _to_entity(model: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity(
id=model.id,
user_id=model.user_id,
name=model.name,
inn=model.inn,
status=model.status,
short_name=model.short_name,
ogrn=model.ogrn,
kpp=model.kpp,
legal_address=model.legal_address,
actual_address=model.actual_address,
bank_details=model.bank_details,
contact_person=model.contact_person,
contact_phone=model.contact_phone,
kyc_verified=model.kyc_verified,
kyc_verified_at=model.kyc_verified_at,
)
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
try:
stmt = select(LegalEntityModel).where(LegalEntityModel.user_id == user_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
if model is None:
return None
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise InternalException(message=f'Database error: {exc}') from exc

View File

@@ -50,6 +50,7 @@ class UserRepository(IUserRepository):
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
account_type=user.account_type,
)
async def get_user_by_id(self, user_id: str) -> UserEntity:

View File

@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository
from src.application.contracts import ILogger
from src.infrastructure.database.repositories import UserRepository, SessionRepository
from src.infrastructure.database.repositories import UserRepository, SessionRepository, LegalEntityRepository
@@ -12,12 +12,14 @@ class UnitOfWork(IUnitOfWork):
self._session: AsyncSession = None
self._user_repository: IUserRepository = None
self._session_repository: ISessionRepository = None
self._legal_entity_repository: ILegalEntityRepository = None
self._logger: ILogger = logger
async def __aenter__(self):
self._logger.debug('UnitOfWork enter')
self._user_repository = None
self._session_repository = None
self._legal_entity_repository = None
self._session = self.session_factory()
return self
@@ -44,3 +46,9 @@ class UnitOfWork(IUnitOfWork):
if self._session_repository is None:
self._session_repository = SessionRepository(session=self._session, logger=self._logger)
return self._session_repository
@property
def legal_entity_repository(self) -> ILegalEntityRepository:
if self._legal_entity_repository is None:
self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger)
return self._legal_entity_repository

View File

@@ -10,6 +10,7 @@ from src.presentation.dependencies.logger import get_logger
from src.presentation.decorators import csrf_protect
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import MeUserPublicResponse
from src.presentation.serializers import me_user_public
account_router = APIRouter()
@@ -36,10 +37,6 @@ account_router = APIRouter()
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
'model': ApiErrorPayload,
},
status.HTTP_404_NOT_FOUND: {
'description': 'Учётная запись не найдена.',
'model': ApiErrorPayload,
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
'description': 'Ошибка валидации входных данных (например, заголовков).',
'model': ApiValidationErrorsPayload,
@@ -55,22 +52,4 @@ async def me(
) -> MeUserPublicResponse:
user = await command(user_id=auth.user_id)
logger.info(f'Get user: {user.id}')
return MeUserPublicResponse(
id=user.id,
email=user.email,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
encrypted_mnemonic=user.encrypted_mnemonic,
phone=user.phone,
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at
)
return me_user_public(user)

View File

@@ -30,6 +30,7 @@ from src.presentation.schemas import (
)
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
from src.presentation.serializers import me_user_public
account_settings_router = APIRouter(prefix='/settings')
@@ -174,25 +175,7 @@ async def set_avatar(
command: SetAvatarCommand = Depends(get_set_avatar_command),
) -> SetAvatarPublicResponse:
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
pub = MeUserPublicResponse(
id=user.id,
email=user.email,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
encrypted_mnemonic=user.encrypted_mnemonic,
phone=user.phone,
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at
)
pub = me_user_public(user)
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
@@ -214,25 +197,7 @@ async def delete_avatar(
command: DeleteAvatarCommand = Depends(get_delete_avatar_command),
) -> MeUserPublicResponse:
user = await command(user_id=auth.user_id)
return MeUserPublicResponse(
id=user.id,
email=user.email,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
encrypted_mnemonic=user.encrypted_mnemonic,
phone=user.phone,
passport_data=user.passport_data,
inn=user.inn,
erc20=user.erc20,
avatar_link=user.avatar_link,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at
)
return me_user_public(user)
@account_settings_router.post(

View File

@@ -5,6 +5,45 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from src.application.domain.entities import UserEntity
from src.application.domain.entities.legal_entity import LegalEntityEntity
class LegalEntityPublicResponse(BaseModel):
model_config = ConfigDict(from_attributes=False)
id: str
name: str
inn: str
status: str
short_name: str | None = None
ogrn: str | None = None
kpp: str | None = None
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict | None = None
contact_person: str | None = None
contact_phone: str | None = None
kyc_verified: bool | None = None
kyc_verified_at: datetime | None = None
@classmethod
def from_entity(cls, entity: LegalEntityEntity) -> LegalEntityPublicResponse:
return cls(
id=entity.id,
name=entity.name,
inn=entity.inn,
status=entity.status,
short_name=entity.short_name,
ogrn=entity.ogrn,
kpp=entity.kpp,
legal_address=entity.legal_address,
actual_address=entity.actual_address,
bank_details=entity.bank_details,
contact_person=entity.contact_person,
contact_phone=entity.contact_phone,
kyc_verified=entity.kyc_verified,
kyc_verified_at=entity.kyc_verified_at,
)
class MeUserPublicResponse(BaseModel):
@@ -27,9 +66,16 @@ class MeUserPublicResponse(BaseModel):
created_at: datetime | None = Field(None, description='Время создания записи')
updated_at: datetime | None = Field(None, description='Время последнего обновления')
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
account_type: str | None = Field(None, description='individual | legal_entity')
legal_entity: LegalEntityPublicResponse | None = Field(None, description='Профиль юрлица')
@classmethod
def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
legal_entity = (
LegalEntityPublicResponse.from_entity(user.legal_entity)
if user.legal_entity is not None
else None
)
return cls(
id=user.id,
email=user.email,
@@ -48,6 +94,8 @@ class MeUserPublicResponse(BaseModel):
created_at=user.created_at,
updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at,
account_type=user.account_type,
legal_entity=legal_entity,
)

View File

@@ -0,0 +1 @@
from src.presentation.serializers.me_user import me_user_payload, me_user_public

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from src.application.domain.entities import UserEntity
from src.presentation.schemas.me_public import MeUserPublicResponse
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
return MeUserPublicResponse.from_user(user)
def me_user_payload(user: UserEntity) -> dict:
return me_user_public(user).model_dump(mode='json')