This commit is contained in:
2026-06-03 13:52:45 +03:00
commit f7309c4b4a
140 changed files with 7134 additions and 0 deletions

View File

@@ -0,0 +1 @@
from src.infrastructure.database.unit_of_work import UnitOfWork

View File

@@ -0,0 +1,22 @@
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlalchemy.ext.asyncio.session import AsyncSession
from typing import AsyncGenerator
from src.infrastructure.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_timeout=settings.DATABASE_POOL_TIMEOUT,
pool_recycle=settings.DATABASE_POOL_RECYCLE,
echo=settings.DATABASE_ECHO
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session

View File

@@ -0,0 +1 @@
from src.infrastructure.database.decorators.transactional import transactional

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from functools import wraps
from typing import Callable, Awaitable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def transactional(method: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(method)
async def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R:
async with self._unit_of_work:
return await method(self, *args, **kwargs)
return wrapper

View File

@@ -0,0 +1,19 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.admin_user import AdminUserModel
from src.infrastructure.database.models.admin_session import AdminSessionModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel
from src.infrastructure.database.models.organization_document import OrganizationDocumentModel
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
__all__ = [
'Base',
'UserModel',
'AdminUserModel',
'AdminSessionModel',
'LegalEntityModel',
'OrganizationWalletModel',
'OrganizationDocumentModel',
'PurchaseRequestModel',
]

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class AdminSessionModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'admin_sessions'
sid: Mapped[str] = mapped_column(
String(26),
unique=True,
index=True,
nullable=False,
default=lambda: str(ULID()),
)
admin_user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('admin_users.id', ondelete='CASCADE'),
index=True,
nullable=False,
)
device_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
first_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refresh_jti_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
Index('ux_admin_sessions_user_device', AdminSessionModel.admin_user_id, AdminSessionModel.device_id, unique=True)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String
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 AdminUserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'admin_users'
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
role: Mapped[str] = mapped_column(String(32), nullable=False, server_default='operator', default='operator')
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase
class Base(AsyncAttrs, DeclarativeBase):
__abstract__ = True
def __repr__(self) -> str:
class_name = self.__class__.__name__
attributes = ', '.join(f"{col.name}={getattr(self, col.name, None)!r}"
for col in self.__table__.columns)
return f"<{class_name}({attributes})>"
def __str__(self) -> str:
class_name = self.__class__.__name__
attributes = ', '.join(f"{col.name}={getattr(self, col.name)}"
for col in self.__table__.columns
if getattr(self, col.name) is not None)
return f"{class_name}({attributes})"

View File

@@ -0,0 +1,42 @@
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),
ForeignKey('admin_users.id'),
nullable=True,
)

View File

@@ -0,0 +1,3 @@
from src.infrastructure.database.models.mixins.audit import AuditTimestampsMixin
from src.infrastructure.database.models.mixins.ulid import UlidPrimaryKeyMixin
from src.infrastructure.database.models.mixins.soft_delete import SoftDeleteMixin

View File

@@ -0,0 +1,16 @@
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class AuditTimestampsMixin:
created_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,6 @@
from sqlalchemy import Boolean
from sqlalchemy.orm import Mapped, mapped_column
class SoftDeleteMixin:
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)

View File

@@ -0,0 +1,8 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
class UlidPrimaryKeyMixin:
id: Mapped[str] = mapped_column(String(26), primary_key=True, default=lambda: str(ULID()))

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'organization_documents'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
document_type: Mapped[str] = mapped_column(String(64), nullable=False)
file_name: Mapped[str] = mapped_column(String(512), nullable=False)
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
uploaded_by: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
class OrganizationWalletModel(Base, UlidPrimaryKeyMixin):
__tablename__ = 'organization_wallets'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
chain: Mapped[str] = mapped_column(String(16), nullable=False)
address: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
derivation_path: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
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 PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'purchase_requests'
organization_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
admin_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
target_wallet_chain: Mapped[str | None] = mapped_column(String(16), nullable=True, server_default='ETH')
target_wallet_address: Mapped[str | None] = mapped_column(String(128), nullable=True)
tx_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
assigned_to: Mapped[str | None] = mapped_column(
String(26),
ForeignKey('admin_users.id'),
nullable=True,
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import Boolean, Date, String, DateTime, Text
from sqlalchemy.orm import Mapped,mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin
class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin):
__tablename__ = 'users'
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
last_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar_link: Mapped[str | None] = mapped_column(Text, nullable=True)
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)

View File

@@ -0,0 +1,17 @@
from src.infrastructure.database.repositories.admin_user_repository import AdminUserRepository
from src.infrastructure.database.repositories.admin_session_repository import AdminSessionRepository
from src.infrastructure.database.repositories.user_repository import UserRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
__all__ = [
'AdminUserRepository',
'AdminSessionRepository',
'UserRepository',
'LegalEntityRepository',
'OrganizationWalletRepository',
'OrganizationDocumentRepository',
'PurchaseRequestRepository',
]

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories import IAdminSessionRepository
from src.application.contracts import ILogger
from src.application.domain.entities.admin_session import AdminSessionEntity
from src.infrastructure.database.models import AdminSessionModel
class AdminSessionRepository(IAdminSessionRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: AdminSessionModel) -> AdminSessionEntity:
return AdminSessionEntity(
sid=m.sid,
admin_user_id=m.admin_user_id,
device_id=m.device_id,
revoked_at=m.revoked_at,
last_seen_at=m.last_seen_at,
refresh_jti_hash=m.refresh_jti_hash,
refresh_expires_at=m.refresh_expires_at,
user_agent=m.user_agent,
first_ip=m.first_ip,
last_ip=m.last_ip,
)
async def get_by_sid(self, sid: str) -> Optional[AdminSessionEntity]:
res = await self._session.execute(select(AdminSessionModel).where(AdminSessionModel.sid == sid))
m = res.scalar_one_or_none()
return self._to_entity(m) if m else None
async def upsert_by_device(
self,
*,
admin_user_id: str,
device_id: str,
sid: str,
refresh_jti_hash: str,
refresh_expires_at: datetime,
user_agent: str | None,
ip: str | None,
now: datetime,
) -> AdminSessionEntity:
res = await self._session.execute(
select(AdminSessionModel).where(
AdminSessionModel.admin_user_id == admin_user_id,
AdminSessionModel.device_id == device_id,
)
)
m = res.scalar_one_or_none()
if m is None:
m = AdminSessionModel(
sid=sid,
admin_user_id=admin_user_id,
device_id=device_id,
revoked_at=None,
last_seen_at=now,
refresh_jti_hash=refresh_jti_hash,
refresh_expires_at=refresh_expires_at,
user_agent=user_agent,
first_ip=ip,
last_ip=ip,
)
self._session.add(m)
else:
m.sid = sid
m.revoked_at = None
m.last_seen_at = now
m.refresh_jti_hash = refresh_jti_hash
m.refresh_expires_at = refresh_expires_at
m.user_agent = user_agent
m.last_ip = ip
await self._session.flush()
return self._to_entity(m)
async def revoke_by_sid(self, sid: str, now: datetime) -> None:
await self._session.execute(
update(AdminSessionModel)
.where(AdminSessionModel.sid == sid, AdminSessionModel.revoked_at.is_(None))
.values(revoked_at=now)
)
await self._session.flush()
async def rotate_refresh_if_match(
self,
*,
sid: str,
old_jti_hash: str,
new_jti_hash: str,
new_refresh_expires_at: datetime,
now: datetime,
ip: str | None,
user_agent: str | None,
) -> bool:
values = {
'refresh_jti_hash': new_jti_hash,
'refresh_expires_at': new_refresh_expires_at,
'last_seen_at': now,
'user_agent': user_agent,
}
if ip is not None:
values['last_ip'] = ip
res = await self._session.execute(
update(AdminSessionModel)
.where(
AdminSessionModel.sid == sid,
AdminSessionModel.revoked_at.is_(None),
AdminSessionModel.refresh_jti_hash == old_jti_hash,
)
.values(**values)
)
await self._session.flush()
return (res.rowcount or 0) > 0

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from fastapi import status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IAdminUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities.admin_user import AdminUserEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import AdminUserModel
class AdminUserRepository(IAdminUserRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: AdminUserModel) -> AdminUserEntity:
return AdminUserEntity(
id=m.id,
email=m.email,
password_hash=m.password_hash,
first_name=m.first_name,
last_name=m.last_name,
role=m.role,
is_active=m.is_active,
last_login_at=m.last_login_at,
created_at=m.created_at,
updated_at=m.updated_at,
)
async def get_by_email(self, email: str) -> AdminUserEntity:
try:
stmt = select(AdminUserModel).where(func.lower(AdminUserModel.email) == email.lower())
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, admin_user_id: str) -> AdminUserEntity:
try:
stmt = select(AdminUserModel).where(AdminUserModel.id == admin_user_id)
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def update_last_login(self, admin_user_id: str, *, last_login_at: datetime) -> None:
await self._session.execute(
update(AdminUserModel)
.where(AdminUserModel.id == admin_user_id)
.values(last_login_at=last_login_at)
)
await self._session.flush()

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from fastapi import status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import ILegalEntityRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import LegalEntityModel
class LegalEntityRepository(ILegalEntityRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity(
id=m.id,
user_id=m.user_id,
name=m.name,
short_name=m.short_name,
inn=m.inn,
ogrn=m.ogrn,
kpp=m.kpp,
legal_address=m.legal_address,
actual_address=m.actual_address,
bank_details=m.bank_details,
contact_person=m.contact_person,
contact_phone=m.contact_phone,
status=m.status,
kyc_verified=m.kyc_verified,
kyc_verified_at=m.kyc_verified_at,
encrypted_mnemonic=m.encrypted_mnemonic,
created_by=m.created_by,
created_at=m.created_at,
updated_at=m.updated_at,
)
async def create(
self,
*,
user_id: str,
name: str,
short_name: str | None,
inn: str,
ogrn: str | None,
kpp: str | None,
legal_address: str | None,
actual_address: str | None,
bank_details: dict[str, Any] | None,
contact_person: str | None,
contact_phone: str | None,
status: str,
kyc_verified: bool,
kyc_verified_at,
created_by: str | None,
) -> LegalEntityEntity:
entity = LegalEntityModel(
user_id=user_id,
name=name,
short_name=short_name,
inn=inn,
ogrn=ogrn,
kpp=kpp,
legal_address=legal_address,
actual_address=actual_address,
bank_details=bank_details,
contact_person=contact_person,
contact_phone=contact_phone,
status=status,
kyc_verified=kyc_verified,
kyc_verified_at=kyc_verified_at,
created_by=created_by,
)
self._session.add(entity)
try:
await self._session.flush()
return self._to_entity(entity)
except IntegrityError:
raise ApplicationException(status_code=409, message='Organization with this INN or user already exists')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, organization_id: str) -> LegalEntityEntity:
res = await self._session.execute(select(LegalEntityModel).where(LegalEntityModel.id == organization_id))
m = res.scalar_one_or_none()
if m is None:
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)
)
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))
return int(res.scalar_one())
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
if not values:
return await self.get_by_id(organization_id)
await self._session.execute(
update(LegalEntityModel).where(LegalEntityModel.id == organization_id).values(**values)
)
await self._session.flush()
return await self.get_by_id(organization_id)
async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None:
await self._session.execute(
update(LegalEntityModel)
.where(LegalEntityModel.id == organization_id)
.values(encrypted_mnemonic=encrypted_mnemonic)
)
await self._session.flush()

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories import IOrganizationDocumentRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationDocumentEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import OrganizationDocumentModel
class OrganizationDocumentRepository(IOrganizationDocumentRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity:
return OrganizationDocumentEntity(
id=m.id,
organization_id=m.organization_id,
document_type=m.document_type,
file_name=m.file_name,
s3_key=m.s3_key,
content_type=m.content_type,
file_size_bytes=m.file_size_bytes,
uploaded_by=m.uploaded_by,
created_at=m.created_at,
)
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
model = OrganizationDocumentModel(
id=document.id,
organization_id=document.organization_id,
document_type=document.document_type,
file_name=document.file_name,
s3_key=document.s3_key,
content_type=document.content_type,
file_size_bytes=document.file_size_bytes,
uploaded_by=document.uploaded_by,
)
self._session.add(model)
try:
await self._session.flush()
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
res = await self._session.execute(
select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id)
)
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Document not found')
return self._to_entity(m)
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
res = await self._session.execute(
select(OrganizationDocumentModel)
.where(OrganizationDocumentModel.organization_id == organization_id)
.order_by(OrganizationDocumentModel.created_at.desc())
)
return [self._to_entity(m) for m in res.scalars().all()]

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories import IOrganizationWalletRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationWalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import OrganizationWalletModel
class OrganizationWalletRepository(IOrganizationWalletRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: OrganizationWalletModel) -> OrganizationWalletEntity:
return OrganizationWalletEntity(
id=m.id,
organization_id=m.organization_id,
chain=m.chain,
address=m.address,
derivation_path=m.derivation_path,
created_at=m.created_at,
)
async def create_many(self, wallets: list[OrganizationWalletEntity]) -> list[OrganizationWalletEntity]:
models = [
OrganizationWalletModel(
id=w.id,
organization_id=w.organization_id,
chain=w.chain,
address=w.address,
derivation_path=w.derivation_path,
)
for w in wallets
]
self._session.add_all(models)
try:
await self._session.flush()
return [self._to_entity(m) for m in models]
except IntegrityError:
raise ApplicationException(status_code=409, message='Wallets already exist for organization')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]:
res = await self._session.execute(
select(OrganizationWalletModel)
.where(OrganizationWalletModel.organization_id == organization_id)
.order_by(OrganizationWalletModel.chain)
)
return [self._to_entity(m) for m in res.scalars().all()]
async def exists_for_organization(self, organization_id: str) -> bool:
res = await self._session.execute(
select(OrganizationWalletModel.id)
.where(OrganizationWalletModel.organization_id == organization_id)
.limit(1)
)
return res.scalar_one_or_none() is not None

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories import IPurchaseRequestRepository
from src.application.contracts import ILogger
from src.application.domain.entities.organization import PurchaseRequestEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import PurchaseRequestModel
class PurchaseRequestRepository(IPurchaseRequestRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, m: PurchaseRequestModel) -> PurchaseRequestEntity:
return PurchaseRequestEntity(
id=m.id,
organization_id=m.organization_id,
status=m.status,
usdt_amount=m.usdt_amount,
rub_amount=m.rub_amount,
exchange_rate=m.exchange_rate,
service_fee_percent=m.service_fee_percent,
comment=m.comment,
admin_comment=m.admin_comment,
target_wallet_chain=m.target_wallet_chain,
target_wallet_address=m.target_wallet_address,
tx_hash=m.tx_hash,
assigned_to=m.assigned_to,
created_at=m.created_at,
updated_at=m.updated_at,
completed_at=m.completed_at,
)
def _apply_filters(self, stmt, *, status: str | None, organization_id: str | None):
if status:
stmt = stmt.where(PurchaseRequestModel.status == status)
if organization_id:
stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id)
return stmt
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id))
m = res.scalar_one_or_none()
if m is None:
raise ApplicationException(status_code=404, message='Purchase request not found')
return self._to_entity(m)
async def list_all(
self,
*,
status: str | None,
organization_id: str | None,
limit: int,
offset: int,
) -> list[PurchaseRequestEntity]:
stmt = select(PurchaseRequestModel).order_by(PurchaseRequestModel.created_at.desc())
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
res = await self._session.execute(stmt.limit(limit).offset(offset))
return [self._to_entity(m) for m in res.scalars().all()]
async def count_all(self, *, status: str | None, organization_id: str | None) -> int:
stmt = select(func.count()).select_from(PurchaseRequestModel)
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
res = await self._session.execute(stmt)
return int(res.scalar_one())
async def update(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity:
if not values:
return await self.get_by_id(request_id)
await self._session.execute(
update(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id).values(**values)
)
await self._session.flush()
return await self.get_by_id(request_id)

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from fastapi import status
from sqlalchemy import 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.enums.account_type import AccountType
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.models import UserModel
class UserRepository(IUserRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
def _to_entity(self, user: UserModel) -> UserEntity:
return UserEntity(
id=user.id,
email=user.email,
password_hash=user.password_hash,
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,
account_type=user.account_type,
provisioned_by=user.provisioned_by,
provisioned_at=user.provisioned_at,
)
async def create_legal_entity_user(
self,
*,
email: str,
password_hash: str,
provisioned_by: str,
provisioned_at: datetime,
kyc_verified: bool,
kyc_verified_at: datetime,
) -> UserEntity:
user = UserModel(
email=email,
password_hash=password_hash,
account_type=AccountType.LEGAL_ENTITY,
provisioned_by=provisioned_by,
provisioned_at=provisioned_at,
kyc_verified=kyc_verified,
kyc_verified_at=kyc_verified_at,
)
self._session.add(user)
try:
await self._session.flush()
return self._to_entity(user)
except IntegrityError:
raise ApplicationException(status_code=409, message='User with this email already exists')
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def get_user_by_email(self, email: str) -> UserEntity:
try:
stmt = select(UserModel).where(UserModel.email == email, UserModel.is_deleted.is_(False))
result = await self._session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise ApplicationException(status_code=404, message='User not found')
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def exists_by_email(self, email: str) -> bool:
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

View File

@@ -0,0 +1,105 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import (
IAdminSessionRepository,
IAdminUserRepository,
ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
)
from src.application.contracts import ILogger
from src.application.domain.exceptions import RefreshConcurrentException
from src.infrastructure.database.repositories import (
AdminSessionRepository,
AdminUserRepository,
LegalEntityRepository,
OrganizationDocumentRepository,
OrganizationWalletRepository,
PurchaseRequestRepository,
UserRepository,
)
class UnitOfWork(IUnitOfWork):
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
self.session_factory = session_factory
self._session: AsyncSession | None = None
self._user_repository: IUserRepository | None = None
self._admin_user_repository: IAdminUserRepository | None = None
self._admin_session_repository: IAdminSessionRepository | None = None
self._legal_entity_repository: ILegalEntityRepository | None = None
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
self._organization_document_repository: IOrganizationDocumentRepository | None = None
self._purchase_request_repository: IPurchaseRequestRepository | None = None
self._logger: ILogger = logger
async def __aenter__(self):
self._session = self.session_factory()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type:
if not isinstance(exc_val, RefreshConcurrentException):
self._logger.error(str(exc_val))
await self._session.rollback()
else:
await self._session.flush()
await self._session.commit()
await self._session.close()
async def commit(self) -> None:
await self._session.commit()
async def rollback(self) -> None:
await self._session.rollback()
@property
def user_repository(self) -> IUserRepository:
if self._user_repository is None:
self._user_repository = UserRepository(session=self._session, logger=self._logger)
return self._user_repository
@property
def admin_user_repository(self) -> IAdminUserRepository:
if self._admin_user_repository is None:
self._admin_user_repository = AdminUserRepository(session=self._session, logger=self._logger)
return self._admin_user_repository
@property
def admin_session_repository(self) -> IAdminSessionRepository:
if self._admin_session_repository is None:
self._admin_session_repository = AdminSessionRepository(session=self._session, logger=self._logger)
return self._admin_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
@property
def organization_wallet_repository(self) -> IOrganizationWalletRepository:
if self._organization_wallet_repository is None:
self._organization_wallet_repository = OrganizationWalletRepository(
session=self._session, logger=self._logger
)
return self._organization_wallet_repository
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository:
if self._organization_document_repository is None:
self._organization_document_repository = OrganizationDocumentRepository(
session=self._session, logger=self._logger
)
return self._organization_document_repository
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository:
if self._purchase_request_repository is None:
self._purchase_request_repository = PurchaseRequestRepository(
session=self._session, logger=self._logger
)
return self._purchase_request_repository