init
This commit is contained in:
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal 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
25
Dockerfile
Normal 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
17
docker-compose.yml
Normal 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
27
pyproject.toml
Normal 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'",
|
||||
]
|
||||
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
43
src/application/abstractions/i_unit_of_work.py
Normal file
43
src/application/abstractions/i_unit_of_work.py
Normal 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: ...
|
||||
7
src/application/abstractions/repositories/__init__.py
Normal file
7
src/application/abstractions/repositories/__init__.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
37
src/application/commands/__init__.py
Normal file
37
src/application/commands/__init__.py
Normal 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',
|
||||
]
|
||||
112
src/application/commands/admin_jwt_refresh.py
Normal file
112
src/application/commands/admin_jwt_refresh.py
Normal 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
|
||||
56
src/application/commands/admin_login.py
Normal file
56
src/application/commands/admin_login.py
Normal 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,
|
||||
)
|
||||
30
src/application/commands/admin_logout.py
Normal file
30
src/application/commands/admin_logout.py
Normal 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})')
|
||||
71
src/application/commands/create_organization.py
Normal file
71
src/application/commands/create_organization.py
Normal 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
|
||||
56
src/application/commands/create_organization_wallets.py
Normal file
56
src/application/commands/create_organization_wallets.py
Normal 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
|
||||
16
src/application/commands/get_admin_me.py
Normal file
16
src/application/commands/get_admin_me.py
Normal 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)
|
||||
46
src/application/commands/organization_commands.py
Normal file
46
src/application/commands/organization_commands.py
Normal 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)
|
||||
31
src/application/commands/organization_document_commands.py
Normal file
31
src/application/commands/organization_document_commands.py
Normal 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
|
||||
112
src/application/commands/purchase_request_commands.py
Normal file
112
src/application/commands/purchase_request_commands.py
Normal 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)
|
||||
62
src/application/commands/upload_organization_document.py
Normal file
62
src/application/commands/upload_organization_document.py
Normal 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
|
||||
7
src/application/contracts/__init__.py
Normal file
7
src/application/contracts/__init__.py
Normal 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
|
||||
20
src/application/contracts/i_cache.py
Normal file
20
src/application/contracts/i_cache.py
Normal 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
|
||||
26
src/application/contracts/i_csrf_service.py
Normal file
26
src/application/contracts/i_csrf_service.py
Normal 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
|
||||
12
src/application/contracts/i_hash_service.py
Normal file
12
src/application/contracts/i_hash_service.py
Normal 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
|
||||
13
src/application/contracts/i_jwt_service.py
Normal file
13
src/application/contracts/i_jwt_service.py
Normal 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
|
||||
68
src/application/contracts/i_logger.py
Normal file
68
src/application/contracts/i_logger.py
Normal 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"""
|
||||
...
|
||||
40
src/application/contracts/i_queue_messanger.py
Normal file
40
src/application/contracts/i_queue_messanger.py
Normal 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
|
||||
14
src/application/contracts/i_user_service.py
Normal file
14
src/application/contracts/i_user_service.py
Normal 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
|
||||
3
src/application/domain/dto/__init__.py
Normal file
3
src/application/domain/dto/__init__.py
Normal 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
|
||||
15
src/application/domain/dto/admin_auth.py
Normal file
15
src/application/domain/dto/admin_auth.py
Normal 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
|
||||
21
src/application/domain/dto/keys.py
Normal file
21
src/application/domain/dto/keys.py
Normal 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
|
||||
17
src/application/domain/dto/token.py
Normal file
17
src/application/domain/dto/token.py
Normal 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
|
||||
33
src/application/domain/dto/user.py
Normal file
33
src/application/domain/dto/user.py
Normal 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
|
||||
|
||||
5
src/application/domain/entities/__init__.py
Normal file
5
src/application/domain/entities/__init__.py
Normal 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']
|
||||
18
src/application/domain/entities/admin_session.py
Normal file
18
src/application/domain/entities/admin_session.py
Normal 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
|
||||
18
src/application/domain/entities/admin_user.py
Normal file
18
src/application/domain/entities/admin_user.py
Normal 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
|
||||
72
src/application/domain/entities/organization.py
Normal file
72
src/application/domain/entities/organization.py
Normal 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
|
||||
34
src/application/domain/entities/user.py
Normal file
34
src/application/domain/entities/user.py
Normal 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
|
||||
2
src/application/domain/enums/__init__.py
Normal file
2
src/application/domain/enums/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
6
src/application/domain/enums/account_type.py
Normal file
6
src/application/domain/enums/account_type.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AccountType(StrEnum):
|
||||
INDIVIDUAL = 'individual'
|
||||
LEGAL_ENTITY = 'legal_entity'
|
||||
7
src/application/domain/enums/admin_role.py
Normal file
7
src/application/domain/enums/admin_role.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AdminRole(StrEnum):
|
||||
OPERATOR = 'operator'
|
||||
COMPLIANCE = 'compliance'
|
||||
SUPERADMIN = 'superadmin'
|
||||
7
src/application/domain/enums/log_format.py
Normal file
7
src/application/domain/enums/log_format.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogFormat(Enum):
|
||||
"""Enum for supported log formats"""
|
||||
TEXT = 'text'
|
||||
JSON = 'json'
|
||||
54
src/application/domain/enums/log_level.py
Normal file
54
src/application/domain/enums/log_level.py
Normal 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
|
||||
10
src/application/domain/exceptions/__init__.py
Normal file
10
src/application/domain/exceptions/__init__.py
Normal 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
|
||||
18
src/application/domain/exceptions/application_exception.py
Normal file
18
src/application/domain/exceptions/application_exception.py
Normal 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}'
|
||||
16
src/application/domain/exceptions/bad_request_exception.py
Normal file
16
src/application/domain/exceptions/bad_request_exception.py
Normal 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,
|
||||
)
|
||||
16
src/application/domain/exceptions/conflict_exception.py
Normal file
16
src/application/domain/exceptions/conflict_exception.py
Normal 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,
|
||||
)
|
||||
16
src/application/domain/exceptions/forbidden_exception.py
Normal file
16
src/application/domain/exceptions/forbidden_exception.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
16
src/application/domain/exceptions/not_found_exception.py
Normal file
16
src/application/domain/exceptions/not_found_exception.py
Normal 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,
|
||||
)
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
16
src/application/domain/exceptions/unauthorized_exception.py
Normal file
16
src/application/domain/exceptions/unauthorized_exception.py
Normal 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
2
src/infrastructure/cache/__init__.py
vendored
Normal 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
18
src/infrastructure/cache/client.py
vendored
Normal 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)
|
||||
20
src/infrastructure/cache/keydb_client.py
vendored
Normal file
20
src/infrastructure/cache/keydb_client.py
vendored
Normal 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
|
||||
1
src/infrastructure/config/__init__.py
Normal file
1
src/infrastructure/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.config.settings import settings
|
||||
270
src/infrastructure/config/settings.py
Normal file
270
src/infrastructure/config/settings.py
Normal 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()
|
||||
1
src/infrastructure/context_vars/__init__.py
Normal file
1
src/infrastructure/context_vars/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.context_vars.trace_id import trace_id_var
|
||||
4
src/infrastructure/context_vars/trace_id.py
Normal file
4
src/infrastructure/context_vars/trace_id.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
trace_id_var: ContextVar[str] = ContextVar('trace_id', default='N/A')
|
||||
0
src/infrastructure/crypto/__init__.py
Normal file
0
src/infrastructure/crypto/__init__.py
Normal file
185
src/infrastructure/crypto/wallet_crypto.py
Normal file
185
src/infrastructure/crypto/wallet_crypto.py
Normal 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
|
||||
1
src/infrastructure/database/__init__.py
Normal file
1
src/infrastructure/database/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.database.unit_of_work import UnitOfWork
|
||||
22
src/infrastructure/database/context.py
Normal file
22
src/infrastructure/database/context.py
Normal 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
|
||||
1
src/infrastructure/database/decorators/__init__.py
Normal file
1
src/infrastructure/database/decorators/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.database.decorators.transactional import transactional
|
||||
15
src/infrastructure/database/decorators/transactional.py
Normal file
15
src/infrastructure/database/decorators/transactional.py
Normal 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
|
||||
19
src/infrastructure/database/models/__init__.py
Normal file
19
src/infrastructure/database/models/__init__.py
Normal 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',
|
||||
]
|
||||
43
src/infrastructure/database/models/admin_session.py
Normal file
43
src/infrastructure/database/models/admin_session.py
Normal 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)
|
||||
21
src/infrastructure/database/models/admin_user.py
Normal file
21
src/infrastructure/database/models/admin_user.py
Normal 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)
|
||||
19
src/infrastructure/database/models/base.py
Normal file
19
src/infrastructure/database/models/base.py
Normal 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})"
|
||||
42
src/infrastructure/database/models/legal_entity.py
Normal file
42
src/infrastructure/database/models/legal_entity.py
Normal 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,
|
||||
)
|
||||
3
src/infrastructure/database/models/mixins/__init__.py
Normal file
3
src/infrastructure/database/models/mixins/__init__.py
Normal 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
|
||||
16
src/infrastructure/database/models/mixins/audit.py
Normal file
16
src/infrastructure/database/models/mixins/audit.py
Normal 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(),
|
||||
)
|
||||
6
src/infrastructure/database/models/mixins/soft_delete.py
Normal file
6
src/infrastructure/database/models/mixins/soft_delete.py
Normal 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)
|
||||
8
src/infrastructure/database/models/mixins/ulid.py
Normal file
8
src/infrastructure/database/models/mixins/ulid.py
Normal 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()))
|
||||
35
src/infrastructure/database/models/organization_document.py
Normal file
35
src/infrastructure/database/models/organization_document.py
Normal 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(),
|
||||
)
|
||||
28
src/infrastructure/database/models/organization_wallet.py
Normal file
28
src/infrastructure/database/models/organization_wallet.py
Normal 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(),
|
||||
)
|
||||
37
src/infrastructure/database/models/purchase_request.py
Normal file
37
src/infrastructure/database/models/purchase_request.py
Normal 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)
|
||||
30
src/infrastructure/database/models/user.py
Normal file
30
src/infrastructure/database/models/user.py
Normal 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)
|
||||
17
src/infrastructure/database/repositories/__init__.py
Normal file
17
src/infrastructure/database/repositories/__init__.py
Normal 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',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
94
src/infrastructure/database/repositories/user_repository.py
Normal file
94
src/infrastructure/database/repositories/user_repository.py
Normal 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
|
||||
105
src/infrastructure/database/unit_of_work.py
Normal file
105
src/infrastructure/database/unit_of_work.py
Normal 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
|
||||
28
src/infrastructure/logger/__init__.py
Normal file
28
src/infrastructure/logger/__init__.py
Normal 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
|
||||
129
src/infrastructure/logger/logger.py
Normal file
129
src/infrastructure/logger/logger.py
Normal 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)
|
||||
1
src/infrastructure/messanger/__init__.py
Normal file
1
src/infrastructure/messanger/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.messanger.rabbit_client import RabbitClient
|
||||
72
src/infrastructure/messanger/rabbit_client.py
Normal file
72
src/infrastructure/messanger/rabbit_client.py
Normal 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,
|
||||
)
|
||||
3
src/infrastructure/security/__init__.py
Normal file
3
src/infrastructure/security/__init__.py
Normal 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
|
||||
81
src/infrastructure/security/csrf.py
Normal file
81
src/infrastructure/security/csrf.py
Normal 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
Reference in New Issue
Block a user