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

145
.gitignore vendored Normal file
View File

@@ -0,0 +1,145 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
generate_password_hash.py
# C extensions
*.so
*.pyd
*.dll
# Distribution / packaging
.Python
build/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache/
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
# Type checkers / linters
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
# Jupyter Notebook
.ipynb_checkpoints/
# Environments
.env
.env.*
.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Poetry
poetry.lock
# Pipenv
Pipfile.lock
# Hatch
.hatch/
# pyenv
.python-version
# Logs
*.log
logs/
# Local databases
*.sqlite3
*.db
# Secrets / credentials
secrets.json
credentials.json
*.pem
*.key
*.crt
# OS generated files
.DS_Store
Thumbs.db
Desktop.ini
# PyCharm / IntelliJ IDEA
.idea/
*.iml
out/
# VS Code (optional)
.vscode/
# Temporary files
*.tmp
*.temp
*.swp
*.swo
*~
# Sphinx docs
docs/_build/
# mkdocs
site/
# celery
celerybeat-schedule
celerybeat.pid
# mypy compiled cache
.mypy_cache/
# pyinstaller
*.manifest
*.spec
# pytest debug
pytestdebug.log
# Local config overrides
config.local.py
settings.local.py
# Vault / local dev secrets
.env.vault
vault.token
.env
.dockerignore
/sql

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src ./src
FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS runtime
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
EXPOSE 8000
CMD ["sh", "-c", "python -m granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-2} --loop uvloop"]

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
admin:
container_name: admin-service
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
PYTHONUNBUFFERED: "1"
APP_MODULE: "src.main:app"
APP_HOST: "0.0.0.0"
APP_PORT: "8000"
APP_WORKERS: "2"
env_file:
- .env
restart: no

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[project]
name = "admin-service"
version = "0.1.0"
description = "Admin service for legal entities and B2B operations"
requires-python = "==3.12.*"
dependencies = [
"acryl-datahub>=1.5.0.19",
"acryl-sqlglot>=25.25.2.dev9",
"aiobotocore>=2.21.0",
"apscheduler==3.11.2",
"asyncpg==0.31.0",
"bcrypt==5.0.0",
"bip-utils>=2.9.3",
"cryptography>=44.0.0",
"dotenv==0.9.9",
"fastapi==0.128.7",
"granian==2.6.1",
"hvac==2.4.0",
"orjson==3.11.7",
"pydantic-settings==2.12.0",
"python-jose==3.5.0",
"python-multipart>=0.0.20",
"python-ulid==3.1.0",
"redis==7.2.0",
"sqlalchemy==2.0.46",
"uvloop==0.22.1; platform_system != 'Windows'",
]

View File

@@ -0,0 +1 @@
from src.application.abstractions.i_unit_of_work import IUnitOfWork

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import (
IAdminSessionRepository,
IAdminUserRepository,
ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository,
IPurchaseRequestRepository,
IUserRepository,
)
@runtime_checkable
class IUnitOfWork(Protocol):
async def __aenter__(self) -> 'IUnitOfWork': ...
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
@property
def user_repository(self) -> IUserRepository: ...
@property
def admin_user_repository(self) -> IAdminUserRepository: ...
@property
def admin_session_repository(self) -> IAdminSessionRepository: ...
@property
def legal_entity_repository(self) -> ILegalEntityRepository: ...
@property
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository: ...
@property
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...

View File

@@ -0,0 +1,7 @@
from src.application.abstractions.repositories.i_user_repository import IUserRepository
from src.application.abstractions.repositories.i_admin_user_repository import IAdminUserRepository
from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository
from src.application.abstractions.repositories.i_organization_document_repository import IOrganizationDocumentRepository
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository

View File

@@ -0,0 +1,44 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Optional
from src.application.domain.entities.admin_session import AdminSessionEntity
class IAdminSessionRepository(ABC):
@abstractmethod
async def get_by_sid(self, sid: str) -> Optional[AdminSessionEntity]:
raise NotImplementedError
@abstractmethod
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:
raise NotImplementedError
@abstractmethod
async def revoke_by_sid(self, sid: str, now: datetime) -> None:
raise NotImplementedError
@abstractmethod
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:
raise NotImplementedError

View File

@@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.admin_user import AdminUserEntity
class IAdminUserRepository(ABC):
@abstractmethod
async def get_by_email(self, email: str) -> AdminUserEntity:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, admin_user_id: str) -> AdminUserEntity:
raise NotImplementedError
@abstractmethod
async def update_last_login(self, admin_user_id: str, *, last_login_at) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,53 @@
from abc import ABC, abstractmethod
from typing import Any
from src.application.domain.entities.organization import LegalEntityEntity
class ILegalEntityRepository(ABC):
@abstractmethod
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:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, organization_id: str) -> LegalEntityEntity:
raise NotImplementedError
@abstractmethod
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
raise NotImplementedError
@abstractmethod
async def update(
self,
organization_id: str,
*,
values: dict[str, Any],
) -> LegalEntityEntity:
raise NotImplementedError
@abstractmethod
async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None:
raise NotImplementedError
@abstractmethod
async def count_all(self) -> int:
raise NotImplementedError

View File

@@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.organization import OrganizationDocumentEntity
class IOrganizationDocumentRepository(ABC):
@abstractmethod
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
raise NotImplementedError
@abstractmethod
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
raise NotImplementedError
@abstractmethod
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
raise NotImplementedError

View File

@@ -0,0 +1,17 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.organization import OrganizationWalletEntity
class IOrganizationWalletRepository(ABC):
@abstractmethod
async def create_many(self, wallets: list[OrganizationWalletEntity]) -> list[OrganizationWalletEntity]:
raise NotImplementedError
@abstractmethod
async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]:
raise NotImplementedError
@abstractmethod
async def exists_for_organization(self, organization_id: str) -> bool:
raise NotImplementedError

View File

@@ -0,0 +1,29 @@
from abc import ABC, abstractmethod
from typing import Any
from src.application.domain.entities.organization import PurchaseRequestEntity
class IPurchaseRequestRepository(ABC):
@abstractmethod
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
raise NotImplementedError
@abstractmethod
async def list_all(
self,
*,
status: str | None,
organization_id: str | None,
limit: int,
offset: int,
) -> list[PurchaseRequestEntity]:
raise NotImplementedError
@abstractmethod
async def update(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity:
raise NotImplementedError
@abstractmethod
async def count_all(self, *, status: str | None, organization_id: str | None) -> int:
raise NotImplementedError

View File

@@ -0,0 +1,27 @@
from abc import ABC, abstractmethod
from datetime import datetime
from src.application.domain.entities import UserEntity
class IUserRepository(ABC):
@abstractmethod
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:
raise NotImplementedError
@abstractmethod
async def get_user_by_email(self, email: str) -> UserEntity:
raise NotImplementedError
@abstractmethod
async def exists_by_email(self, email: str) -> bool:
raise NotImplementedError

View File

@@ -0,0 +1,37 @@
from src.application.commands.admin_login import AdminLoginCommand
from src.application.commands.get_admin_me import GetAdminMeCommand
from src.application.commands.create_organization import CreateOrganizationCommand
from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand
from src.application.commands.upload_organization_document import UploadOrganizationDocumentCommand
from src.application.commands.organization_commands import (
ListOrganizationsCommand,
GetOrganizationCommand,
UpdateOrganizationCommand,
)
from src.application.commands.organization_document_commands import (
ListOrganizationDocumentsCommand,
GetOrganizationDocumentCommand,
)
from src.application.commands.purchase_request_commands import (
ListPurchaseRequestsCommand,
GetPurchaseRequestCommand,
UpdatePurchaseRequestStatusCommand,
SetPurchaseRequestQuoteCommand,
)
__all__ = [
'AdminLoginCommand',
'GetAdminMeCommand',
'CreateOrganizationCommand',
'CreateOrganizationWalletsCommand',
'UploadOrganizationDocumentCommand',
'ListOrganizationsCommand',
'GetOrganizationCommand',
'UpdateOrganizationCommand',
'ListPurchaseRequestsCommand',
'GetPurchaseRequestCommand',
'UpdatePurchaseRequestStatusCommand',
'SetPurchaseRequestQuoteCommand',
'ListOrganizationDocumentsCommand',
'GetOrganizationDocumentCommand',
]

View File

@@ -0,0 +1,112 @@
import asyncio
from datetime import datetime, timezone, timedelta
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, IJwtService, ILogger, ICache
from src.application.domain.dto import RefreshTokenPayload
from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional
class AdminJwtRefreshCommand:
_LOCK_PREFIX = 'admin:jwt:refresh:lock:'
_LOCK_TTL_SECONDS = 15
_LOCK_WAIT_ATTEMPTS = 40
_LOCK_WAIT_INTERVAL_SECONDS = 0.05
def __init__(
self,
unit_of_work: IUnitOfWork,
hash_service: IHashService,
jwt_service: IJwtService,
cache: ICache,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._jwt_service = jwt_service
self._cache = cache
self._logger = logger
@transactional
async def __call__(self, *, refresh_token: str, ip: str | None, user_agent: str | None) -> tuple[str, str]:
now = datetime.now(timezone.utc)
payload: RefreshTokenPayload = await self._jwt_service.decode_refresh_token(refresh_token)
sid = payload.sid
admin_user_id = payload.sub
jti = payload.jti
lock_key = f'{self._LOCK_PREFIX}{sid}'
locked = await self._cache.set_nx(lock_key, '1', self._LOCK_TTL_SECONDS)
if not locked:
for _ in range(self._LOCK_WAIT_ATTEMPTS):
await asyncio.sleep(self._LOCK_WAIT_INTERVAL_SECONDS)
if await self._cache.get(lock_key) is None:
raise RefreshConcurrentException()
raise ApplicationException(status_code=429, message='Refresh in progress')
try:
return await self._refresh_locked(
sid=sid,
admin_user_id=admin_user_id,
jti=jti,
now=now,
ip=ip,
user_agent=user_agent,
)
finally:
await self._cache.delete(lock_key)
async def _refresh_locked(
self,
*,
sid: str,
admin_user_id: str,
jti: str,
now: datetime,
ip: str | None,
user_agent: str | None,
) -> tuple[str, str]:
sess = await self._unit_of_work.admin_session_repository.get_by_sid(sid)
if sess is None:
raise ApplicationException(status_code=401, message='Session not found')
if sess.revoked_at is not None:
raise ApplicationException(status_code=401, message='Session revoked')
if sess.refresh_expires_at is None or sess.refresh_expires_at <= now:
raise ApplicationException(status_code=401, message='Session expired')
if str(sess.admin_user_id) != str(admin_user_id):
raise ApplicationException(status_code=401, message='Invalid session subject')
ok = await self._hash_service.verify(plain_value=jti, hashed_value=sess.refresh_jti_hash)
if not ok:
await self._unit_of_work.admin_session_repository.revoke_by_sid(sid=sid, now=now)
raise ApplicationException(status_code=401, message='Refresh token reuse detected')
admin = await self._unit_of_work.admin_user_repository.get_by_id(admin_user_id)
new_jti = str(ULID())
new_jti_hash = await self._hash_service.hash(value=new_jti)
new_refresh_expires_at = now + timedelta(seconds=int(settings.JWT_REFRESH_TTL_SECONDS))
rotated = await self._unit_of_work.admin_session_repository.rotate_refresh_if_match(
sid=sid,
old_jti_hash=sess.refresh_jti_hash,
new_jti_hash=new_jti_hash,
new_refresh_expires_at=new_refresh_expires_at,
now=now,
ip=ip,
user_agent=user_agent,
)
if not rotated:
raise RefreshConcurrentException()
access = await self._jwt_service.create_access_token(
user_id=admin_user_id, sid=sid, role=admin.role
)
refresh = await self._jwt_service.create_refresh_token(
user_id=admin_user_id, sid=sid, refresh_jti=new_jti
)
return access, refresh

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime, timezone
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, IJwtService, ILogger
from src.application.domain.dto.admin_auth import AdminLoginDto
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class AdminLoginCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
hash_service: IHashService,
jwt_service: IJwtService,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._jwt_service = jwt_service
self._logger = logger
@transactional
async def __call__(self, *, email: str, password: str) -> AdminLoginDto:
email = (email or '').strip().lower()
admin = await self._unit_of_work.admin_user_repository.get_by_email(email)
if not admin.is_active:
raise ApplicationException(status_code=403, message='Admin account is inactive')
ok = await self._hash_service.verify(plain_value=password, hashed_value=admin.password_hash)
if not ok:
self._logger.warning(f'Admin login failed for {email}')
raise ApplicationException(status_code=401, message='Invalid credentials')
now = datetime.now(timezone.utc)
await self._unit_of_work.admin_user_repository.update_last_login(admin.id, last_login_at=now)
access_token = await self._jwt_service.create_access_token(
user_id=admin.id,
role=admin.role,
)
self._logger.info(f'Admin logged in admin_user_id={admin.id}')
return AdminLoginDto(
id=admin.id,
email=admin.email,
first_name=admin.first_name,
last_name=admin.last_name,
role=admin.role,
access_token=access_token,
last_login_at=now,
)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime, timezone
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IJwtService, ILogger
from src.application.domain.dto import RefreshTokenPayload
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class AdminLogoutCommand:
def __init__(self, unit_of_work: IUnitOfWork, jwt_service: IJwtService, logger: ILogger):
self._unit_of_work = unit_of_work
self._jwt_service = jwt_service
self._logger = logger
@transactional
async def __call__(self, *, refresh_token: str | None) -> None:
if not refresh_token:
return
try:
payload: RefreshTokenPayload = await self._jwt_service.decode_refresh_token(refresh_token)
except ApplicationException:
self._logger.debug('Logout: refresh token invalid/expired, skipping revoke')
return
now = datetime.now(timezone.utc)
await self._unit_of_work.admin_session_repository.revoke_by_sid(sid=payload.sid, now=now)
self._logger.info(f'Logout: session revoked (sid={payload.sid}, admin_user_id={payload.sub})')

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger
from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class CreateOrganizationCommand:
def __init__(self, unit_of_work: IUnitOfWork, hash_service: IHashService, logger: ILogger):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._logger = logger
@transactional
async def __call__(
self,
*,
admin_user_id: str,
email: str,
password: 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 = 'active',
) -> LegalEntityEntity:
email = (email or '').strip().lower()
if await self._unit_of_work.user_repository.exists_by_email(email):
raise ApplicationException(status_code=409, message='User with this email already exists')
now = datetime.now(timezone.utc)
password_hash = await self._hash_service.hash(value=password)
user = await self._unit_of_work.user_repository.create_legal_entity_user(
email=email,
password_hash=password_hash,
provisioned_by=admin_user_id,
provisioned_at=now,
kyc_verified=True,
kyc_verified_at=now,
)
org = await self._unit_of_work.legal_entity_repository.create(
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=True,
kyc_verified_at=now,
created_by=admin_user_id,
)
self._logger.info(f'Organization created id={org.id} user_id={user.id}')
return org

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.organization import OrganizationWalletEntity
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.crypto.wallet_crypto import (
ALL_CHAINS,
derive_all_addresses,
encrypt_mnemonic,
generate_mnemonic,
is_crypto_ready,
)
from src.infrastructure.database.decorators import transactional
class CreateOrganizationWalletsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, *, organization_id: str) -> list[OrganizationWalletEntity]:
if not is_crypto_ready():
raise ApplicationException(status_code=503, message='Crypto service not ready')
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
if org.encrypted_mnemonic:
raise ApplicationException(status_code=409, message='Wallets already created for organization')
if await self._unit_of_work.organization_wallet_repository.exists_for_organization(organization_id):
raise ApplicationException(status_code=409, message='Wallets already exist for organization')
mnemonic = generate_mnemonic()
derived = derive_all_addresses(mnemonic)
blob = encrypt_mnemonic(mnemonic)
mnemonic = ''
await self._unit_of_work.legal_entity_repository.set_encrypted_mnemonic(organization_id, blob)
wallets = [
OrganizationWalletEntity(
id=str(ULID()),
organization_id=organization_id,
chain=item.chain,
address=item.address,
derivation_path=item.derivation_path,
)
for item in derived
if item.chain in ALL_CHAINS
]
saved = await self._unit_of_work.organization_wallet_repository.create_many(wallets)
self._logger.info(f'Wallets created for organization_id={organization_id} chains={len(saved)}')
return saved

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.admin_user import AdminUserEntity
from src.infrastructure.database.decorators import transactional
class GetAdminMeCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, admin_user_id: str) -> AdminUserEntity:
return await self._unit_of_work.admin_user_repository.get_by_id(admin_user_id)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
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.infrastructure.database.decorators import transactional
class ListOrganizationsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
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()
return items, total
class GetOrganizationCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> LegalEntityEntity:
return await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
class UpdateOrganizationCommand:
ALLOWED_FIELDS = frozenset({
'name', 'short_name', 'ogrn', 'kpp', 'legal_address', 'actual_address',
'bank_details', 'contact_person', 'contact_phone', 'status',
})
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
filtered = {k: v for k, v in values.items() if k in self.ALLOWED_FIELDS and v is not None}
return await self._unit_of_work.legal_entity_repository.update(organization_id, values=filtered)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from src.application.abstractions import IUnitOfWork
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.decorators import transactional
class ListOrganizationDocumentsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]:
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
return await self._unit_of_work.organization_document_repository.list_by_organization(organization_id)
class GetOrganizationDocumentCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity:
doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id)
if doc.organization_id != organization_id:
raise ApplicationException(status_code=404, message='Document not found')
return doc

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from src.application.abstractions import IUnitOfWork
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.decorators import transactional
VALID_STATUSES = frozenset({
'submitted', 'in_review', 'quote_sent', 'payment_pending',
'payment_received', 'usdt_sent', 'completed', 'rejected', 'cancelled',
})
class ListPurchaseRequestsCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(
self,
*,
status: str | None = None,
organization_id: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PurchaseRequestEntity], int]:
items = await self._unit_of_work.purchase_request_repository.list_all(
status=status,
organization_id=organization_id,
limit=limit,
offset=offset,
)
total = await self._unit_of_work.purchase_request_repository.count_all(
status=status,
organization_id=organization_id,
)
return items, total
class GetPurchaseRequestCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, request_id: str) -> PurchaseRequestEntity:
return await self._unit_of_work.purchase_request_repository.get_by_id(request_id)
class UpdatePurchaseRequestStatusCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(
self,
request_id: str,
*,
status: str,
admin_comment: str | None = None,
assigned_to: str | None = None,
tx_hash: str | None = None,
) -> PurchaseRequestEntity:
if status not in VALID_STATUSES:
raise ApplicationException(status_code=400, message='Invalid status')
values: dict[str, Any] = {'status': status}
if admin_comment is not None:
values['admin_comment'] = admin_comment
if assigned_to is not None:
values['assigned_to'] = assigned_to
if tx_hash is not None:
values['tx_hash'] = tx_hash
if status == 'completed':
values['completed_at'] = datetime.now(timezone.utc)
return await self._unit_of_work.purchase_request_repository.update(request_id, values=values)
class SetPurchaseRequestQuoteCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(
self,
request_id: str,
*,
rub_amount: Decimal,
exchange_rate: Decimal,
service_fee_percent: Decimal | None = None,
admin_comment: str | None = None,
) -> PurchaseRequestEntity:
values: dict[str, Any] = {
'rub_amount': rub_amount,
'exchange_rate': exchange_rate,
'status': 'quote_sent',
}
if service_fee_percent is not None:
values['service_fee_percent'] = service_fee_percent
if admin_comment is not None:
values['admin_comment'] = admin_comment
return await self._unit_of_work.purchase_request_repository.update(request_id, values=values)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from ulid import ULID
from src.application.abstractions import IUnitOfWork
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.decorators import transactional
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
ALLOWED_DOCUMENT_TYPES = frozenset({
'charter', 'inn_certificate', 'ogrn_certificate',
'bank_details', 'kyc_representative', 'power_of_attorney', 'other',
})
class UploadOrganizationDocumentCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
s3_service: S3DocumentsService,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._s3 = s3_service
self._logger = logger
@transactional
async def __call__(
self,
*,
organization_id: str,
admin_user_id: str,
document_type: str,
file_name: str,
content_type: str,
body: bytes,
) -> OrganizationDocumentEntity:
if document_type not in ALLOWED_DOCUMENT_TYPES:
raise ApplicationException(status_code=400, message='Invalid document type')
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
document_id = str(ULID())
s3_key = self._s3.build_object_key(organization_id, document_id, file_name)
await self._s3.upload_bytes(key=s3_key, body=body, content_type=content_type)
entity = OrganizationDocumentEntity(
id=document_id,
organization_id=organization_id,
document_type=document_type,
file_name=file_name,
s3_key=s3_key,
content_type=content_type,
file_size_bytes=len(body),
uploaded_by=admin_user_id,
)
saved = await self._unit_of_work.organization_document_repository.create(entity)
self._logger.info(f'Document uploaded id={saved.id} org={organization_id}')
return saved

View File

@@ -0,0 +1,7 @@
from src.application.contracts.i_hash_service import IHashService
from src.application.contracts.i_logger import ILogger
from src.application.contracts.i_user_service import IUserService
from src.application.contracts.i_jwt_service import IJwtService
from src.application.contracts.i_csrf_service import ICsrfService
from src.application.contracts.i_cache import ICache
from src.application.contracts.i_queue_messanger import IQueueMessanger

View File

@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
class ICache(ABC):
@abstractmethod
async def set(self, key: str, value: str, ttl: int) -> bool:
raise NotImplementedError
@abstractmethod
async def set_nx(self, key: str, value: str, ttl: int) -> bool:
raise NotImplementedError
@abstractmethod
async def get(self, key: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def delete(self, key: str) -> bool:
raise NotImplementedError

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Optional, Mapping
class ICsrfService(ABC):
@abstractmethod
def issue(self, subject: Optional[str] = None) -> str:
raise NotImplementedError
@abstractmethod
def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
raise NotImplementedError
@abstractmethod
def verify_pair(
self,
cookie_token: Optional[str],
header_token: Optional[str],
expected_subject: Optional[str] = None,
) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,12 @@
from abc import ABC, abstractmethod
class IHashService(ABC):
@abstractmethod
async def hash(self, value: str) -> str:
raise NotImplementedError
@abstractmethod
async def verify(self, hashed_value: str, plain_value: str) -> bool:
raise NotImplementedError

View File

@@ -0,0 +1,13 @@
from abc import ABC, abstractmethod
from src.application.domain.dto import AccessTokenPayload
class IJwtService(ABC):
@abstractmethod
async def create_access_token(self, user_id: str, *, role: str) -> str:
raise NotImplementedError
@abstractmethod
async def decode_access_token(self, token: str) -> AccessTokenPayload:
raise NotImplementedError

View File

@@ -0,0 +1,68 @@
from typing import Protocol, Optional, Callable
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.log_level import LogLevel
class ILogger(Protocol):
"""Interface for synchronous logger with ContextVar support for trace_id."""
log_format: LogFormat
min_level: LogLevel
id_generator: Optional[Callable[[], str]]
instance_id: str
def set_format(self, log_format: LogFormat) -> None:
"""Set log format using LogFormat enum"""
...
def set_min_level(self, level: LogLevel) -> None:
"""Set minimum log level"""
...
def new_trace_id(self) -> str:
"""Create and set new trace_id in context"""
...
def set_trace_id(self, trace_id: str) -> None:
"""Set existing trace_id in context"""
...
def get_trace_id(self) -> str:
"""Get current trace_id from context"""
...
def clear_trace_id(self) -> None:
"""Clear the trace_id in the context"""
...
def set_instance_id(self, instance_id: str) -> None:
"""Set service instance id (ULID recommended)"""
...
def get_instance_id(self) -> str:
"""Get current service instance id"""
...
def debug(self, message: str) -> None:
"""Log debug message"""
...
def info(self, message: str) -> None:
"""Log info message"""
...
def warning(self, message: str) -> None:
"""Log warning message"""
...
def error(self, message: str) -> None:
"""Log error message"""
...
def critical(self, message: str) -> None:
"""Log critical message"""
...
def exception(self, message: str) -> None:
"""Log exception with traceback"""
...

View File

@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from typing import Mapping, Any
class IQueueMessanger(ABC):
@abstractmethod
async def connect(self) -> None:
raise NotImplementedError
@abstractmethod
async def close(self) -> None:
raise NotImplementedError
@abstractmethod
async def publish_to_queue(
self,
queue: str,
message: Any,
*,
persist: bool = True,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
raise NotImplementedError
@abstractmethod
async def publish(
self,
message: Any,
*,
exchange: str,
routing_key: str,
persist: bool = True,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod
from src.application.domain.dto import UserCreatedDto, UserLoginDto
class IUserService(ABC):
@abstractmethod
async def registration(self, email: str, password: str) -> UserCreatedDto:
raise NotImplementedError
@abstractmethod
async def login(self, email: str, password: str) -> UserLoginDto:
raise NotImplementedError

View File

@@ -0,0 +1,3 @@
from src.application.domain.dto.admin_auth import AdminLoginDto
from src.application.domain.dto.token import AccessTokenPayload, AdminAuthContext
from src.application.domain.dto.keys import JwtKeySet, JwtKeyPair

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
class AdminLoginDto:
id: str
email: str
first_name: str | None
last_name: str | None
role: str
access_token: str
last_login_at: datetime | None = None

View File

@@ -0,0 +1,21 @@
from dataclasses import dataclass
from typing import Optional, Dict
@dataclass(frozen=True)
class JwtKeyPair:
kid: str
private_key_pem: str
public_key_pem: str
@dataclass(frozen=True)
class JwtKeySet:
active: JwtKeyPair
previous: Optional[JwtKeyPair] = None
def public_keys_by_kid(self) -> Dict[str, str]:
out = {self.active.kid: self.active.public_key_pem}
if self.previous:
out[self.previous.kid] = self.previous.public_key_pem
return out

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel
class AccessTokenPayload(BaseModel):
sub: str
type: str
role: str | None = None
iat: int
nbf: int
exp: int
iss: str | None = None
aud: str | None = None
class AdminAuthContext(BaseModel):
admin_user_id: str
role: str

View File

@@ -0,0 +1,33 @@
from dataclasses import dataclass
from datetime import datetime, date
@dataclass(slots=True)
class UserCreatedDto:
id: str
email: str
access_token: str
refresh_token: str
@dataclass(slots=True)
class UserLoginDto:
id: str | None = None
email: str | None = None
first_name: str | None = None
middle_name: str | None = None
last_name: str | None = None
birth_date: date | None = None
encrypted_mnemonic: str | None = None
phone: str | None = None
passport_data: str | None = None
inn: str | None = None
erc20: str | None = None
avatar_link: str | None = None
kyc_verified: bool | None = None
access_token: str | None = None
refresh_token: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
kyc_verified_at: datetime | None = None

View File

@@ -0,0 +1,5 @@
from src.application.domain.entities.user import UserEntity
from src.application.domain.entities.admin_user import AdminUserEntity
from src.application.domain.entities.admin_session import AdminSessionEntity
__all__ = ['UserEntity', 'AdminUserEntity', 'AdminSessionEntity']

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
class AdminSessionEntity:
sid: str
admin_user_id: str
device_id: str
revoked_at: datetime | None
last_seen_at: datetime
refresh_jti_hash: str | None
refresh_expires_at: datetime | None
user_agent: str | None = None
first_ip: str | None = None
last_ip: str | None = None

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
class AdminUserEntity:
id: str
email: str
password_hash: str
first_name: str | None
last_name: str | None
role: str
is_active: bool
last_login_at: datetime | None
created_at: datetime | None = None
updated_at: datetime | None = None

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
@dataclass
class LegalEntityEntity:
id: str
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: datetime | None
encrypted_mnemonic: str | None
created_by: str | None
created_at: datetime | None = None
updated_at: datetime | None = None
@dataclass
class OrganizationWalletEntity:
id: str
organization_id: str
chain: str
address: str
derivation_path: str
created_at: datetime | None = None
@dataclass
class OrganizationDocumentEntity:
id: str
organization_id: str
document_type: str
file_name: str
s3_key: str
content_type: str
file_size_bytes: int
uploaded_by: str | None
created_at: datetime | None = None
@dataclass
class PurchaseRequestEntity:
id: str
organization_id: str
status: str
usdt_amount: Decimal
rub_amount: Decimal | None
exchange_rate: Decimal | None
service_fee_percent: Decimal | None
comment: str | None
admin_comment: str | None
target_wallet_chain: str | None
target_wallet_address: str | None
tx_hash: str | None
assigned_to: str | None
created_at: datetime | None = None
updated_at: datetime | None = None
completed_at: datetime | None = None

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
@dataclass(slots=True)
class UserEntity:
id: str | None = None
email: str | None = None
password_hash: str | None = None
first_name: str | None = None
middle_name: str | None = None
last_name: str | None = None
birth_date: date | None = None
encrypted_mnemonic: str | None = None
phone: str | None = None
passport_data: str | None = None
inn: str | None = None
erc20: str | None = None
avatar_link: str | None = None
kyc_verified: bool | None = None
is_deleted: bool | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
kyc_verified_at: datetime | None = None
account_type: str | None = None
provisioned_by: str | None = None
provisioned_at: datetime | None = None

View File

@@ -0,0 +1,2 @@
from src.application.domain.enums.log_level import LogLevel
from src.application.domain.enums.log_format import LogFormat

View File

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

View File

@@ -0,0 +1,7 @@
from enum import StrEnum
class AdminRole(StrEnum):
OPERATOR = 'operator'
COMPLIANCE = 'compliance'
SUPERADMIN = 'superadmin'

View File

@@ -0,0 +1,7 @@
from enum import Enum
class LogFormat(Enum):
"""Enum for supported log formats"""
TEXT = 'text'
JSON = 'json'

View File

@@ -0,0 +1,54 @@
from enum import Enum
class LogLevel(Enum):
DEBUG = 10
INFO = 20
WARNING = 30
ERROR = 40
CRITICAL = 50
EXCEPTION = 60
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return f"[{self.value}, '{self.name}']"
def __eq__(self, other: object) -> bool:
if isinstance(other, LogLevel):
return self.value == other.value
if isinstance(other, int):
return self.value == other
return NotImplemented
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __lt__(self, other: object) -> bool:
if isinstance(other, LogLevel):
return self.value < other.value
if isinstance(other, int):
return self.value < other
return NotImplemented
def __le__(self, other: object) -> bool:
if isinstance(other, LogLevel):
return self.value <= other.value
if isinstance(other, int):
return self.value <= other
return NotImplemented
def __gt__(self, other: object) -> bool:
if isinstance(other, LogLevel):
return self.value > other.value
if isinstance(other, int):
return self.value > other
return NotImplemented
def __ge__(self, other: object) -> bool:
if isinstance(other, LogLevel):
return self.value >= other.value
if isinstance(other, int):
return self.value >= other
return NotImplemented

View File

@@ -0,0 +1,10 @@
from src.application.domain.exceptions.application_exception import ApplicationException
from src.application.domain.exceptions.bad_request_exception import BadRequestException
from src.application.domain.exceptions.conflict_exception import ConflictException
from src.application.domain.exceptions.forbidden_exception import ForbiddenException
from src.application.domain.exceptions.internal_server_exception import InternalServerException
from src.application.domain.exceptions.not_found_exception import NotFoundException
from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
from src.application.domain.exceptions.too_many_requests_exception import TooManyRequestsException
from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
from src.application.domain.exceptions.refresh_concurrent_exception import RefreshConcurrentException

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from typing import Mapping
class ApplicationException(Exception):
def __init__(
self,
status_code: int,
message: str,
headers: Mapping[str, str] | None = None,
):
super().__init__(message)
self.status_code = status_code
self.message = message
self.headers = headers
def __str__(self):
return f'{self.status_code}: {self.message}'

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class BadRequestException(ApplicationException):
def __init__(
self,
message: str = 'Bad Request',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class ConflictException(ApplicationException):
def __init__(
self,
message: str = 'Conflict',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class ForbiddenException(ApplicationException):
def __init__(
self,
message: str = 'Forbidden',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class InternalServerException(ApplicationException):
def __init__(
self,
message: str = 'Internal Server Error',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class NotFoundException(ApplicationException):
def __init__(
self,
message: str = 'Not Found',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,10 @@
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class RefreshConcurrentException(ApplicationException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_200_OK,
message='Refresh already handled',
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class ServiceUnavailableException(ApplicationException):
def __init__(
self,
message: str = 'Service Unavailable',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class TooManyRequestsException(ApplicationException):
def __init__(
self,
message: str = 'Too Many Requests',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
message=message,
headers=headers,
)

View File

@@ -0,0 +1,16 @@
from typing import Mapping
from starlette import status
from src.application.domain.exceptions.application_exception import ApplicationException
class UnauthorizedException(ApplicationException):
def __init__(
self,
message: str = 'Unauthorized',
headers: Mapping[str, str] | None = None,
):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
message=message,
headers=headers,
)

2
src/infrastructure/cache/__init__.py vendored Normal file
View File

@@ -0,0 +1,2 @@
from src.infrastructure.cache.client import create_redis_client
from src.infrastructure.cache.keydb_client import KeydbCache

18
src/infrastructure/cache/client.py vendored Normal file
View File

@@ -0,0 +1,18 @@
import redis.asyncio as redis
from redis.asyncio.client import Redis
from src.infrastructure.config import settings
def create_redis_client() -> Redis:
kw = {
'max_connections': 50,
'decode_responses': True,
'socket_timeout': 5,
'socket_connect_timeout': 5,
'health_check_interval': 30,
'retry_on_timeout': True,
'socket_keepalive': True,
}
if settings.REDIS_PASSWORD:
kw['password'] = settings.REDIS_PASSWORD
return redis.from_url(settings.REDIS_URL, **kw)

View File

@@ -0,0 +1,20 @@
from redis.asyncio.client import Redis
from src.application.contracts import ICache
class KeydbCache(ICache):
def __init__(self, redis_client: Redis):
self._r = redis_client
async def set(self, key: str, value: str, ttl: int) -> None:
return bool(await self._r.set(key, value, ex=ttl))
async def set_nx(self, key: str, value: str, ttl: int) -> bool:
return bool(await self._r.set(key, value, ex=ttl, nx=True))
async def get(self, key: str) -> str | None:
return await self._r.get(key)
async def delete(self, key: str) -> bool:
deleted = await self._r.delete(key)
return deleted > 0

View File

@@ -0,0 +1 @@
from src.infrastructure.config.settings import settings

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
from functools import lru_cache
from typing import List, Literal
import os
from dotenv import load_dotenv, find_dotenv
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
env_file = find_dotenv(".env")
if env_file:
load_dotenv(env_file)
def normalize_vault_base_url(raw: str) -> str:
u = raw.strip().rstrip('/')
if not u:
return raw.strip()
if '://' not in u:
return f'https://{u}'
return u
class Settings(BaseSettings):
VAULT_ADDR: str = Field(default='http://localhost:8200')
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
VAULT_SECRET_ID: str = Field(
...,
description='AppRole secret_id',
validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'),
)
VAULT_NAMESPACE: str | None = Field(default=None)
VAULT_MOUNT_POINT: str = Field(default='dev-secrets')
VAULT_JWT_KID_PATH: str = 'jwt/kid'
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto/master'
VAULT_LEGAL_DOCS_S3_SECRET_PATH: str = 's3/legal-docs'
JWT_KEYS_REFRESH_SECONDS: int = 3600
DATABASE_HOST: str
DATABASE_PORT: int = Field(default=5432, ge=1, le=65535)
DATABASE_NAME: str
DATABASE_USER: str
DATABASE_PASSWORD: str
DATABASE_POOL_SIZE: int = 10
DATABASE_MAX_OVERFLOW: int = 20
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYCLE: int = 3600
DATABASE_ECHO: bool = False
ADMIN_COOKIE_SECURE: bool = False
ADMIN_COOKIE_DOMAIN: str | None = '.elcsa.ru'
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*admin\.elcsa\.ru(:\d+)?$'
DOCS_USERNAME: str = 'admin'
DOCS_PASSWORD: str = 'admin'
JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60
ADMIN_JWT_ISSUER: str | None = 'admin-service'
JWT_AUDIENCE: str | None = None
JWT_ALGORITHM: str = 'RS256'
REDIS_HOST: str = 'localhost'
REDIS_PORT: int = 6379
REDIS_PASSWORD: str | None = None
REDIS_DB: int = 0
LEGAL_DOCS_S3_BUCKET: str = ''
LEGAL_DOCS_S3_REGION: str = 'us-east-1'
LEGAL_DOCS_S3_ACCESS_KEY_ID: str = ''
LEGAL_DOCS_S3_SECRET_ACCESS_KEY: str = ''
LEGAL_DOCS_S3_ENDPOINT_URL: str = ''
LEGAL_DOCS_S3_KEY_PREFIX: str = 'legal-docs'
LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS: int = 3600
RATE_LIMIT_REQUESTS: int = 60
RATE_LIMIT_WINDOW: int = 60
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'TEXT'
@field_validator('VAULT_ADDR', mode='before')
@classmethod
def vault_addr_scheme(cls, v):
if v is None or not isinstance(v, str):
return v
return normalize_vault_base_url(v)
@field_validator('ADMIN_COOKIE_DOMAIN', mode='before')
@classmethod
def normalize_admin_cookie_domain(cls, v):
if v is None or (isinstance(v, str) and not v.strip()):
return '.elcsa.ru'
s = str(v).strip()
sl = s.lower()
if sl in ('.elcsa.ru', 'elcsa.ru'):
return '.elcsa.ru'
if sl.endswith('.elcsa.ru') and not sl.startswith('.'):
return '.elcsa.ru'
return s
@field_validator('REDIS_PASSWORD', mode='before')
@classmethod
def empty_redis_password_to_none(cls, v):
if v is None or (isinstance(v, str) and not v.strip()):
return None
return v
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
case_sensitive=True,
extra='ignore',
populate_by_name=True,
)
@staticmethod
def _vault_kv(mapping: dict, *keys: str):
for k in keys:
if k in mapping and mapping[k] is not None:
return mapping[k]
return None
@classmethod
def _apply_s3_from_vault(cls, data: dict, s3: dict) -> None:
bucket = cls._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket', 'LEGAL_DOCS_S3_BUCKET')
endpoint = cls._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'LEGAL_DOCS_S3_ENDPOINT_URL')
ak = cls._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'LEGAL_DOCS_S3_ACCESS_KEY_ID')
sk = cls._vault_kv(
s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'LEGAL_DOCS_S3_SECRET_ACCESS_KEY'
)
if bucket:
data['LEGAL_DOCS_S3_BUCKET'] = str(bucket).strip()
if endpoint:
data['LEGAL_DOCS_S3_ENDPOINT_URL'] = str(endpoint).strip()
if ak:
data['LEGAL_DOCS_S3_ACCESS_KEY_ID'] = str(ak).strip()
if sk:
data['LEGAL_DOCS_S3_SECRET_ACCESS_KEY'] = str(sk).strip()
region = cls._vault_kv(s3, 's3_region', 'S3_REGION', 'region', 'LEGAL_DOCS_S3_REGION')
if region:
data['LEGAL_DOCS_S3_REGION'] = str(region).strip()
prefix = cls._vault_kv(s3, 'key_prefix', 'LEGAL_DOCS_S3_KEY_PREFIX', 's3_key_prefix')
if prefix:
data['LEGAL_DOCS_S3_KEY_PREFIX'] = str(prefix).strip()
@model_validator(mode='before')
@classmethod
def load_from_vault(cls, data: dict):
if not isinstance(data, dict):
return data
addr_raw = data.get('VAULT_ADDR') or os.getenv('VAULT_ADDR') or 'http://localhost:8200'
addr = normalize_vault_base_url(addr_raw)
data['VAULT_ADDR'] = addr
role_id = data.get('VAULT_ROLE_ID') or os.getenv('VAULT_ROLE_ID')
secret_id = (
data.get('VAULT_SECRET_ID')
or data.get('VAULT_SECRET_TOKEN')
or os.getenv('VAULT_SECRET_ID')
or os.getenv('VAULT_SECRET_TOKEN')
)
namespace = data.get('VAULT_NAMESPACE')
if namespace is None:
namespace = os.getenv('VAULT_NAMESPACE')
namespace = namespace if namespace else None
mount = data.get('VAULT_MOUNT_POINT') or os.getenv('VAULT_MOUNT_POINT') or 'dev-secrets'
if not role_id or not secret_id:
raise RuntimeError(
'VAULT_ROLE_ID and VAULT_SECRET_ID (or VAULT_SECRET_TOKEN) are required for Vault AppRole'
)
data['VAULT_ROLE_ID'] = str(role_id).strip()
data['VAULT_SECRET_ID'] = str(secret_id).strip()
client = create_hvac_client_from_approle(
url=addr,
role_id=role_id,
secret_id=secret_id,
namespace=namespace,
timeout=5,
)
def read_secret(path: str) -> dict:
return read_kv2_secret(client=client, mount_point=mount, path=path)
def read_secret_optional(path: str) -> dict:
try:
return read_secret(path)
except Exception:
return {}
database = read_secret('database')
db_ci = {str(k).lower(): v for k, v in database.items()}
def db_nonempty(key: str) -> bool:
v = db_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
required_db = ['host', 'name', 'user', 'password', 'port']
missing_db = [k for k in required_db if not db_nonempty(k)]
if missing_db:
raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}')
data['DATABASE_HOST'] = str(db_ci['host']).strip()
data['DATABASE_PORT'] = int(db_ci['port'])
data['DATABASE_NAME'] = str(db_ci['name']).strip()
data['DATABASE_USER'] = str(db_ci['user']).strip()
data['DATABASE_PASSWORD'] = str(db_ci['password']).strip()
redis_secret = read_secret_optional('redis')
if redis_secret:
rd_ci = {str(k).lower(): v for k, v in redis_secret.items()}
def rd_set(field: str, env_key: str, *, as_int: bool = False) -> None:
v = rd_ci.get(field)
if v is None:
return
if isinstance(v, str) and not v.strip():
return
if as_int:
data[env_key] = int(v)
else:
data[env_key] = str(v).strip()
rd_set('host', 'REDIS_HOST')
rd_set('port', 'REDIS_PORT', as_int=True)
rd_set('password', 'REDIS_PASSWORD')
rd_set('db', 'REDIS_DB', as_int=True)
s3_path = (
data.get('VAULT_LEGAL_DOCS_S3_SECRET_PATH')
or os.getenv('VAULT_LEGAL_DOCS_S3_SECRET_PATH')
or 's3/legal-docs'
)
s3_secret = read_secret_optional(str(s3_path))
if s3_secret:
cls._apply_s3_from_vault(data, s3_secret)
return data
@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}"
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
)
@property
def REDIS_URL(self) -> str:
return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
@property
def EXCLUDED_PATHS(self) -> List[str]:
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1 @@
from src.infrastructure.context_vars.trace_id import trace_id_var

View File

@@ -0,0 +1,4 @@
from contextvars import ContextVar
trace_id_var: ContextVar[str] = ContextVar('trace_id', default='N/A')

View File

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
import base64
import os
import re
from dataclasses import dataclass
from bip_utils import (
Bip39MnemonicGenerator,
Bip39SeedGenerator,
Bip39WordsNum,
Bip44,
Bip44Changes,
Bip44Coins,
Bip84,
Bip84Coins,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret
KEY_LEN = 32
IV_LEN = 12
TAG_LEN = 16
DERIVATION_PATHS: dict[str, str] = {
'ETH': "m/44'/60'/0'/0/0",
'BSC': "m/44'/60'/0'/0/0",
'BTC': "m/84'/0'/0'/0/0",
'TRX': "m/44'/195'/0'/0/0",
'SOL': "m/44'/501'/0'/0'",
}
ALL_CHAINS: tuple[str, ...] = ('ETH', 'BSC', 'BTC', 'TRX', 'SOL')
_master_key: bytes | None = None
@dataclass(frozen=True)
class DerivedWallet:
chain: str
address: str
derivation_path: str
class CryptoNotReadyError(RuntimeError):
pass
def load_master_key_from_vault(
*,
vault_addr: str,
vault_role_id: str,
vault_secret_id: str,
vault_namespace: str | None,
mount_point: str,
path: str,
) -> None:
global _master_key
if _master_key is not None:
return
client = create_hvac_client_from_approle(
url=vault_addr,
role_id=vault_role_id,
secret_id=vault_secret_id,
namespace=vault_namespace,
timeout=5,
)
secrets = read_kv2_secret(client=client, mount_point=mount_point, path=path)
if not secrets:
raise RuntimeError(f'Failed to load crypto master key from Vault path {path}')
raw = secrets.get('key')
if not raw or not isinstance(raw, str):
if secrets.get('master_key') or secrets.get('MASTER_KEY'):
raise RuntimeError('Crypto master key misconfigured: alternate field present but canonical "key" missing')
raise RuntimeError('Crypto master key invalid: expected hex string in Vault field "key"')
if not re.fullmatch(r'[0-9a-fA-F]{64}', raw):
raise RuntimeError('Crypto master key invalid: must be 64-char hex (32 bytes)')
key = bytes.fromhex(raw)
if len(key) != KEY_LEN:
raise RuntimeError(f'Crypto master key invalid: got {len(key)} bytes, expected {KEY_LEN}')
_master_key = key
def is_crypto_ready() -> bool:
return _master_key is not None and len(_master_key) == KEY_LEN
def generate_mnemonic() -> str:
return str(Bip39MnemonicGenerator().FromWordsNumber(Bip39WordsNum.WORDS_NUM_12))
def derive_all_addresses(mnemonic: str) -> list[DerivedWallet]:
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
eth_addr = (
eth_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
btc_addr = (
btc_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON)
trx_addr = (
trx_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
sol_addr = (
sol_ctx.Purpose()
.Coin()
.Account(0)
.Change(Bip44Changes.CHAIN_EXT)
.AddressIndex(0)
.PublicKey()
.ToAddress()
)
return [
DerivedWallet(chain='ETH', address=eth_addr, derivation_path=DERIVATION_PATHS['ETH']),
DerivedWallet(chain='BSC', address=eth_addr, derivation_path=DERIVATION_PATHS['BSC']),
DerivedWallet(chain='BTC', address=btc_addr, derivation_path=DERIVATION_PATHS['BTC']),
DerivedWallet(chain='TRX', address=trx_addr, derivation_path=DERIVATION_PATHS['TRX']),
DerivedWallet(chain='SOL', address=sol_addr, derivation_path=DERIVATION_PATHS['SOL']),
]
def encrypt_mnemonic(plaintext: str) -> str:
if _master_key is None:
raise CryptoNotReadyError('Crypto service not ready')
if not plaintext:
raise ValueError('encrypt_mnemonic: plaintext must be non-empty')
iv = os.urandom(IV_LEN)
aesgcm = AESGCM(_master_key)
ct_with_tag = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None)
tag = ct_with_tag[-TAG_LEN:]
ct = ct_with_tag[:-TAG_LEN]
return base64.b64encode(iv + ct + tag).decode('ascii')
def decrypt_mnemonic(blob: str) -> str:
if _master_key is None:
raise CryptoNotReadyError('Crypto service not ready')
if not blob:
raise ValueError('decrypt_mnemonic: blob must be non-empty')
raw = base64.b64decode(blob)
if len(raw) < IV_LEN + TAG_LEN + 1:
raise ValueError('decrypt_mnemonic: blob too short')
iv = raw[:IV_LEN]
tag = raw[-TAG_LEN:]
ct = raw[IV_LEN:-TAG_LEN]
aesgcm = AESGCM(_master_key)
try:
return aesgcm.decrypt(iv, ct + tag, None).decode('utf-8')
except Exception as exc:
raise ValueError('decrypt_mnemonic: authentication failed') from exc

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

View File

@@ -0,0 +1,28 @@
from src.application.contracts import ILogger
from src.application.domain.enums import LogFormat
from src.application.domain.enums import LogLevel
from src.infrastructure.config.settings import settings
from src.infrastructure.logger.logger import Logger
log_levels = {
'DEBUG': LogLevel.DEBUG,
'INFO': LogLevel.INFO,
'WARNING': LogLevel.WARNING,
'ERROR': LogLevel.ERROR,
'CRITICAL': LogLevel.CRITICAL,
'EXCEPTION': LogLevel.EXCEPTION,
}
log_formats = {
'JSON': LogFormat.JSON,
'TEXT': LogFormat.TEXT,
}
logger = Logger(
min_level=log_levels.get(settings.LOG_LEVEL, LogLevel.INFO),
log_format=log_formats.get(settings.LOG_FORMAT, LogFormat.JSON),
)
def get_logger() -> ILogger:
return logger

View File

@@ -0,0 +1,129 @@
import traceback
import inspect
import sys
import json
from datetime import datetime
from typing import Callable, Optional, Any
from ulid import ULID
from src.application.contracts import ILogger
from src.application.domain.enums import LogFormat, LogLevel
from src.infrastructure.context_vars import trace_id_var
class Logger(ILogger):
_instance = None
__default_format = LogFormat.JSON
def __new__(cls, *args: Any, **kwargs: Any) -> "Logger":
if cls._instance is None:
cls._instance = super(Logger, cls).__new__(cls)
return cls._instance
def __init__(
self,
log_format: LogFormat = __default_format,
min_level: LogLevel = LogLevel.INFO,
id_generator: Optional[Callable[[], str]] = lambda: str(ULID()),
instance_id: str = "N/A",
):
self.log_format = log_format
self.min_level = min_level
self.id_generator = id_generator
self.instance_id = instance_id
def set_instance_id(self, instance_id: str) -> None:
self.instance_id = instance_id
def get_instance_id(self) -> str:
return self.instance_id
def set_format(self, log_format: LogFormat) -> None:
if not isinstance(log_format, LogFormat):
raise ValueError("Log format must be an instance of LogFormat enum")
self.log_format = log_format
def set_min_level(self, level: LogLevel) -> None:
self.min_level = level
def new_trace_id(self) -> str:
trace_id = str(ULID()) if self.id_generator is None else self.id_generator()
trace_id_var.set(trace_id)
return trace_id
def set_trace_id(self, trace_id: str) -> None:
trace_id_var.set(trace_id)
def get_trace_id(self) -> str:
return trace_id_var.get()
def clear_trace_id(self) -> None:
trace_id_var.set("N/A")
def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]:
current_frame = inspect.currentframe()
if (
current_frame
and current_frame.f_back
and current_frame.f_back.f_back
and current_frame.f_back.f_back.f_back
):
frame = current_frame.f_back.f_back.f_back
filename = frame.f_code.co_filename
line_number = frame.f_lineno
else:
filename = "unknown"
line_number = 0
log_data = {
"timestamp": datetime.now().isoformat(),
"level": level.name,
"instance_id": self.instance_id,
"file": filename,
"line": line_number,
"trace_id": trace_id_var.get(),
"message": message,
}
if level == LogLevel.EXCEPTION:
log_data["exception"] = traceback.format_exc()
return log_data
def _log(self, level: LogLevel, message: str) -> None:
if level >= self.min_level:
log_data = self._prepare_log_data(level, message)
if self.log_format == LogFormat.JSON:
log_message = json.dumps(log_data, ensure_ascii=False)
else:
log_message = (
f"{log_data['timestamp']} - {log_data['level']} - "
f"{log_data['instance_id']} - {log_data['trace_id']} - "
f"{log_data['file']}:{log_data['line']} - "
f"{log_data['message']}"
)
if "exception" in log_data:
log_message += f"\nTraceback:\n{log_data['exception']}"
self._write(log_message)
def _write(self, message: str) -> None:
sys.stdout.write(message + "\n")
def debug(self, message: str) -> None:
self._log(LogLevel.DEBUG, message)
def info(self, message: str) -> None:
self._log(LogLevel.INFO, message)
def warning(self, message: str) -> None:
self._log(LogLevel.WARNING, message)
def error(self, message: str) -> None:
self._log(LogLevel.ERROR, message)
def critical(self, message: str) -> None:
self._log(LogLevel.CRITICAL, message)
def exception(self, message: str) -> None:
self._log(LogLevel.EXCEPTION, message)

View File

@@ -0,0 +1 @@
from src.infrastructure.messanger.rabbit_client import RabbitClient

View File

@@ -0,0 +1,72 @@
from typing import Any, Mapping
from faststream.rabbit import RabbitBroker
from src.application.contracts import IQueueMessanger
from src.infrastructure.config import settings
class RabbitClient(IQueueMessanger):
def __init__(self) -> None:
self._broker = RabbitBroker(
settings.RABBIT_URL,
)
self._connected = False
async def connect(self) -> None:
if self._connected:
return
await self._broker.connect()
self._connected = True
async def close(self) -> None:
if not self._connected:
return
await self._broker.close()
self._connected = False
async def _ensure_connected(self) -> None:
if not self._connected:
await self.connect()
async def publish_to_queue(
self,
queue: str,
message: Any,
*,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
queue=queue,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)
async def publish(
self,
message: Any,
*,
exchange: str,
routing_key: str,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
exchange=exchange,
routing_key=routing_key,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)

View File

@@ -0,0 +1,3 @@
from src.infrastructure.security.jwt import JwtService
from src.infrastructure.security.csrf import CsrfService
from src.infrastructure.security.hash import HashService

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import secrets
from typing import Any, Optional, Mapping
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from src.application.contracts import ICsrfService
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config.settings import settings
class CsrfService(ICsrfService):
COOKIE_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
SALT = "csrf"
TTL_SECONDS = 3600
def __init__(self) -> None:
self._serializer = URLSafeTimedSerializer(
secret_key=settings.CSRF_SECRET_KEY,
salt=self.SALT,
)
@property
def cookie_name(self) -> str:
return self.COOKIE_NAME
@property
def header_name(self) -> str:
return self.HEADER_NAME
@property
def ttl_seconds(self) -> int:
return self.TTL_SECONDS
def issue(self, subject: Optional[str] = None) -> str:
payload = {
"sub": subject,
"nonce": secrets.token_urlsafe(32),
}
return self._serializer.dumps(payload)
def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]:
try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired:
raise ApplicationException(
status_code=403,
message="CSRF token expired",
)
except BadSignature:
raise ApplicationException(
status_code=403,
message="CSRF token invalid",
)
if expected_subject is not None and data.get("sub") != expected_subject:
raise ApplicationException(
status_code=403,
message="CSRF token subject mismatch",
)
return data
def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
cookie_token = cookies.get(self.COOKIE_NAME)
header_token = headers.get(self.HEADER_NAME)
return cookie_token, header_token
def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None:
if not cookie_token or not header_token:
raise ApplicationException(
status_code=403,
message="CSRF token missing",
)
if not secrets.compare_digest(cookie_token, header_token):
raise ApplicationException(
status_code=403,
message="CSRF token mismatch",
)
self.verify(cookie_token, expected_subject=expected_subject)

Some files were not shown because too many files have changed in this diff Show More