feat: add avatars
This commit is contained in:
@@ -36,4 +36,8 @@ class IUserRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
raise NotImplementedError
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
from src.application.commands.set_phone import SetPhoneCommand
|
||||
from src.application.commands.set_avatar import SetAvatarCommand
|
||||
from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand
|
||||
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
|
||||
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand
|
||||
|
||||
54
src/application/commands/set_avatar.py
Normal file
54
src/application/commands/set_avatar.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from PIL import UnidentifiedImageError
|
||||
from ulid import ULID
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ICache, ILogger, IS3
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.application.domain.exceptions import BadRequestException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
from src.infrastructure.media.webp import image_bytes_to_webp
|
||||
|
||||
|
||||
class SetAvatarCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._s3 = s3
|
||||
|
||||
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
|
||||
try:
|
||||
webp_bytes = image_bytes_to_webp(image_bytes)
|
||||
except UnidentifiedImageError as exc:
|
||||
raise BadRequestException(message='Unsupported or corrupt image') from exc
|
||||
except Exception as exc:
|
||||
self._logger.exception(str(exc))
|
||||
raise BadRequestException(message='Could not process image') from exc
|
||||
|
||||
pid = user_id.replace('/', '').replace('.', '_')
|
||||
name_id = str(ULID())
|
||||
ts = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
prefix = settings.S3_AVATAR_KEY_PREFIX.strip().strip('/')
|
||||
fname = f'{name_id}_{pid}_{ts}.webp'
|
||||
object_key = f'{prefix}/{fname}' if prefix else fname
|
||||
|
||||
try:
|
||||
url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp')
|
||||
except ClientError as exc:
|
||||
self._logger.exception(str(exc))
|
||||
raise ServiceUnavailableException(message='S3 upload failed') from exc
|
||||
|
||||
user = await self._save_avatar_link(user_id, url)
|
||||
await self._cache.set_user(user_id, user)
|
||||
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
|
||||
return user, len(webp_bytes)
|
||||
|
||||
@transactional
|
||||
async def _save_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
|
||||
return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link)
|
||||
@@ -3,4 +3,5 @@ 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_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_s3 import IS3
|
||||
|
||||
9
src/application/contracts/i_s3.py
Normal file
9
src/application/contracts/i_s3.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IS3(Protocol):
|
||||
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
|
||||
...
|
||||
@@ -20,6 +20,7 @@ class UserEntity:
|
||||
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
|
||||
|
||||
1
src/infrastructure/cache/keydb_client.py
vendored
1
src/infrastructure/cache/keydb_client.py
vendored
@@ -42,6 +42,7 @@ class KeydbCache(ICache):
|
||||
'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.isoformat() if user.created_at else None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, List, Literal
|
||||
from typing import Any, List, Literal, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
@@ -39,6 +39,7 @@ class Settings(BaseSettings):
|
||||
VAULT_DOCS_SECRET_PATH: str = 'docs'
|
||||
VAULT_JWT_KID_PATH: str = 'jwt/kid'
|
||||
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
|
||||
VAULT_S3_SECRET_PATH: str = 's3/avatars'
|
||||
|
||||
DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL')
|
||||
DATABASE_HOST: str = ''
|
||||
@@ -89,6 +90,15 @@ class Settings(BaseSettings):
|
||||
RABBIT_CONNECT_TIMEOUT: int = 5
|
||||
RABBIT_EMAIL_CODE_QUEUE: str = 'email.verification_code'
|
||||
|
||||
S3_BUCKET: str = ''
|
||||
S3_REGION: str = 'us-east-1'
|
||||
S3_ACCESS_KEY_ID: str = ''
|
||||
S3_SECRET_ACCESS_KEY: str = ''
|
||||
S3_ENDPOINT_URL: str = ''
|
||||
S3_PUBLIC_BASE_URL: str = ''
|
||||
S3_REGRU_PUBLIC_WEBSITE_HOST: bool = True
|
||||
S3_AVATAR_KEY_PREFIX: str = 'avatars'
|
||||
|
||||
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
|
||||
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON'
|
||||
|
||||
@@ -99,7 +109,78 @@ class Settings(BaseSettings):
|
||||
return str(value)
|
||||
return ''
|
||||
|
||||
def _reset_s3_config(self) -> None:
|
||||
object.__setattr__(self, 'S3_BUCKET', '')
|
||||
object.__setattr__(self, 'S3_ACCESS_KEY_ID', '')
|
||||
object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', '')
|
||||
object.__setattr__(self, 'S3_ENDPOINT_URL', '')
|
||||
object.__setattr__(self, 'S3_PUBLIC_BASE_URL', '')
|
||||
object.__setattr__(self, 'S3_REGION', 'us-east-1')
|
||||
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True)
|
||||
object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', 'avatars')
|
||||
|
||||
@staticmethod
|
||||
def _vault_kv(mapping: Mapping[str, Any], *keys: str) -> Any:
|
||||
for k in keys:
|
||||
if k in mapping and mapping[k] is not None:
|
||||
return mapping[k]
|
||||
return None
|
||||
|
||||
def _apply_s3_from_vault_secret(self, s3: dict[str, Any]) -> None:
|
||||
bucket_name = (
|
||||
self._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket')
|
||||
or self._vault_kv(s3, 'S3_BUCKET', 'bucketName')
|
||||
)
|
||||
endpoint_url = (
|
||||
self._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'ENDPOINT_URL')
|
||||
or self._vault_kv(s3, 'endpoint')
|
||||
)
|
||||
ak = (
|
||||
self._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'access_key_id')
|
||||
or self._vault_kv(s3, 'AWS_ACCESS_KEY_ID')
|
||||
)
|
||||
sk = (
|
||||
self._vault_kv(s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY')
|
||||
or self._vault_kv(s3, 'AWS_SECRET_ACCESS_KEY')
|
||||
)
|
||||
if bucket_name is None or str(bucket_name).strip() == '':
|
||||
raise ValueError('Vault S3 secret must contain bucket_name')
|
||||
if endpoint_url is None or str(endpoint_url).strip() == '':
|
||||
raise ValueError('Vault S3 secret must contain s3_endpoint_url')
|
||||
if ak is None or str(ak).strip() == '':
|
||||
raise ValueError('Vault S3 secret must contain s3_access_key_id')
|
||||
if sk is None or str(sk).strip() == '':
|
||||
raise ValueError('Vault S3 secret must contain s3_secret_access_key')
|
||||
object.__setattr__(self, 'S3_BUCKET', str(bucket_name).strip())
|
||||
object.__setattr__(self, 'S3_ENDPOINT_URL', str(endpoint_url).strip())
|
||||
object.__setattr__(self, 'S3_ACCESS_KEY_ID', str(ak).strip())
|
||||
object.__setattr__(self, 'S3_SECRET_ACCESS_KEY', str(sk).strip())
|
||||
region = (
|
||||
self._vault_kv(s3, 's3_region', 'S3_REGION', 'region')
|
||||
)
|
||||
if region is not None and str(region).strip() != '':
|
||||
object.__setattr__(self, 'S3_REGION', str(region).strip())
|
||||
public_base = (
|
||||
self._vault_kv(s3, 's3_public_base_url', 'S3_PUBLIC_BASE_URL', 'public_base_url')
|
||||
or self._vault_kv(s3, 'public_url')
|
||||
)
|
||||
if public_base is not None and str(public_base).strip() != '':
|
||||
object.__setattr__(self, 'S3_PUBLIC_BASE_URL', str(public_base).strip())
|
||||
prefix = self._vault_kv(s3, 'avatar_key_prefix', 'S3_AVATAR_KEY_PREFIX', 's3_avatar_key_prefix')
|
||||
if prefix is not None and str(prefix).strip() != '':
|
||||
object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', str(prefix).strip())
|
||||
rf = (
|
||||
self._vault_kv(s3, 's3_reg_ru_public_website_host', 'S3_REGRU_PUBLIC_WEBSITE_HOST')
|
||||
)
|
||||
if rf is not None:
|
||||
v = str(rf).strip().lower()
|
||||
if v in {'1', 'true', 'yes', 'on'}:
|
||||
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True)
|
||||
elif v in {'0', 'false', 'no', 'off'}:
|
||||
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', False)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
self._reset_s3_config()
|
||||
if not self.VAULT_ROLE_ID.strip() or not self.VAULT_SECRET_ID.strip():
|
||||
if not self.DATABASE_URL:
|
||||
raise ValueError(
|
||||
@@ -164,6 +245,16 @@ class Settings(BaseSettings):
|
||||
if p is not None:
|
||||
object.__setattr__(self, 'DOCS_PASSWORD', str(p))
|
||||
|
||||
s3_rel_path = self.VAULT_S3_SECRET_PATH.strip()
|
||||
if s3_rel_path:
|
||||
try:
|
||||
s3_secret_data = client.read_secret(s3_rel_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f'Vault S3 secret not readable at mount {self.VAULT_MOUNT_POINT}/{s3_rel_path}: {exc!r}'
|
||||
) from exc
|
||||
self._apply_s3_from_vault_secret(s3_secret_data)
|
||||
|
||||
if not self.DATABASE_URL:
|
||||
raise ValueError('Database URL could not be built from Vault database secret')
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
|
||||
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(String(2048), nullable=True, default=None)
|
||||
|
||||
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)
|
||||
|
||||
@@ -44,6 +44,7 @@ class UserRepository(IUserRepository):
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified_at=user.kyc_verified_at,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
@@ -88,6 +89,9 @@ class UserRepository(IUserRepository):
|
||||
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
|
||||
return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic)
|
||||
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
|
||||
return await self._update_field(user_id, avatar_link=avatar_link)
|
||||
|
||||
async def get_password_hash(self, user_id: str) -> str:
|
||||
try:
|
||||
user = await self._get_active_user(user_id)
|
||||
|
||||
18
src/infrastructure/media/webp.py
Normal file
18
src/infrastructure/media/webp.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def image_bytes_to_webp(raw: bytes, *, quality: int = 82) -> bytes:
|
||||
im = Image.open(BytesIO(raw))
|
||||
if im.mode == 'P':
|
||||
im = im.convert('RGBA')
|
||||
elif im.mode == 'LA':
|
||||
im = im.convert('RGBA')
|
||||
elif im.mode not in ('RGBA', 'RGB'):
|
||||
im = im.convert('RGB')
|
||||
out = BytesIO()
|
||||
im.save(out, format='WEBP', quality=quality)
|
||||
return out.getvalue()
|
||||
64
src/infrastructure/storage/s3_service.py
Normal file
64
src/infrastructure/storage/s3_service.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
|
||||
class S3Service:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
region: str,
|
||||
access_key_id: str | None,
|
||||
secret_access_key: str | None,
|
||||
public_base_url: str | None,
|
||||
endpoint_url: str | None,
|
||||
use_reg_ru_website_public_host: bool,
|
||||
):
|
||||
self._bucket = bucket
|
||||
self._region = region or 'us-east-1'
|
||||
self._access_key_id = access_key_id
|
||||
self._secret_access_key = secret_access_key
|
||||
pb = (public_base_url or '').strip().rstrip('/')
|
||||
self._public_base_url = pb if pb else None
|
||||
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None
|
||||
self._use_reg_ru_website_public_host = use_reg_ru_website_public_host
|
||||
|
||||
def _object_url(self, key: str) -> str:
|
||||
if self._public_base_url:
|
||||
return f'{self._public_base_url}/{key}'
|
||||
endpoint = self._endpoint_url
|
||||
if endpoint:
|
||||
if (
|
||||
self._use_reg_ru_website_public_host
|
||||
and 's3.regru.cloud' in endpoint.lower()
|
||||
):
|
||||
return f'https://{self._bucket}.website.regru.cloud/{key}'
|
||||
return f'{endpoint}/{self._bucket}/{key}'
|
||||
region = self._region
|
||||
if region == 'us-east-1':
|
||||
host = 's3.amazonaws.com'
|
||||
else:
|
||||
host = f's3.{region}.amazonaws.com'
|
||||
return f'https://{self._bucket}.{host}/{key}'
|
||||
|
||||
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
|
||||
session = get_session()
|
||||
kw: dict[str, object] = {'region_name': self._region}
|
||||
aid = self._access_key_id
|
||||
sk = self._secret_access_key
|
||||
ep = self._endpoint_url
|
||||
if aid:
|
||||
kw['aws_access_key_id'] = aid
|
||||
if sk:
|
||||
kw['aws_secret_access_key'] = sk
|
||||
if ep:
|
||||
kw['endpoint_url'] = ep
|
||||
async with session.create_client('s3', **kw) as client:
|
||||
await client.put_object(
|
||||
Bucket=self._bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type,
|
||||
)
|
||||
return self._object_url(key)
|
||||
@@ -64,12 +64,3 @@ class VaultClient:
|
||||
return self.read_secret(path)
|
||||
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for path in paths:
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
result.update(self.read_secret(path))
|
||||
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_get_me_command,
|
||||
get_set_phone_command,
|
||||
get_set_avatar_command,
|
||||
get_set_encrypted_mnemonic_start_command,
|
||||
get_set_encrypted_mnemonic_complete_command,
|
||||
get_update_bank_details_start_command,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import GetMeCommand, SetPhoneCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
|
||||
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService
|
||||
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
|
||||
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
|
||||
from src.presentation.dependencies.cache import get_cache
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.dependencies.queue_messanger import get_rabbit
|
||||
from src.presentation.dependencies.security import get_hash_service
|
||||
from src.presentation.dependencies.s3_storage import get_s3_storage
|
||||
from src.presentation.dependencies.unit_of_work import get_unit_of_work
|
||||
|
||||
|
||||
@@ -25,6 +26,15 @@ def get_set_phone_command(
|
||||
return SetPhoneCommand(logger=logger, unit_of_work=unit_of_work, cache=cache)
|
||||
|
||||
|
||||
def get_set_avatar_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
s3: IS3 = Depends(get_s3_storage),
|
||||
) -> SetAvatarCommand:
|
||||
return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
|
||||
|
||||
|
||||
def get_set_encrypted_mnemonic_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
|
||||
32
src/presentation/dependencies/s3_storage.py
Normal file
32
src/presentation/dependencies/s3_storage.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.contracts import IS3
|
||||
from src.application.domain.exceptions import ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.storage.s3_service import S3Service
|
||||
|
||||
_s3singleton: IS3 | None = None
|
||||
|
||||
|
||||
def get_s3_storage() -> IS3:
|
||||
global _s3singleton
|
||||
if _s3singleton is not None:
|
||||
return _s3singleton
|
||||
if not settings.S3_BUCKET.strip():
|
||||
raise ServiceUnavailableException(message='S3 is not configured')
|
||||
endpoint = settings.S3_ENDPOINT_URL.strip()
|
||||
pub = settings.S3_PUBLIC_BASE_URL.strip()
|
||||
if not pub and not endpoint:
|
||||
raise ServiceUnavailableException(message='Set S3_ENDPOINT_URL (or S3_PUBLIC_BASE_URL for a custom CDN base)')
|
||||
ak = settings.S3_ACCESS_KEY_ID.strip() or None
|
||||
sk = settings.S3_SECRET_ACCESS_KEY.strip() or None
|
||||
_s3singleton = S3Service(
|
||||
bucket=settings.S3_BUCKET.strip(),
|
||||
region=settings.S3_REGION.strip() or 'us-east-1',
|
||||
access_key_id=ak,
|
||||
secret_access_key=sk,
|
||||
public_base_url=pub if pub else None,
|
||||
endpoint_url=endpoint if endpoint else None,
|
||||
use_reg_ru_website_public_host=settings.S3_REGRU_PUBLIC_WEBSITE_HOST,
|
||||
)
|
||||
return _s3singleton
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
@@ -8,37 +8,42 @@ from src.presentation.decorators import require_access_token
|
||||
from src.presentation.dependencies.commands import get_get_me_command
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.decorators import csrf_protect
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
account_router = APIRouter()
|
||||
|
||||
@account_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
|
||||
@account_router.get(
|
||||
path='/',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=MeUserPublicResponse,
|
||||
summary='Текущий пользователь',
|
||||
description='Возвращает профиль авторизованного пользователя. Защита CSRF: cookie csrf_token и заголовок X-CSRF-Token с тем же значением.',
|
||||
responses={
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Ошибка валидации входных данных (например, заголовков).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
},
|
||||
)
|
||||
@csrf_protect()
|
||||
async def me(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetMeCommand = Depends(get_get_me_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
):
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
logger.info(f'Get user: {user.id}')
|
||||
return ORJSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'middle_name': user.middle_name,
|
||||
'last_name': user.last_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'encrypted_mnemonic': user.encrypted_mnemonic,
|
||||
'phone': user.phone,
|
||||
'passport_data': user.passport_data,
|
||||
'inn': user.inn,
|
||||
'erc20': user.erc20,
|
||||
'kyc_verified': user.kyc_verified,
|
||||
'is_deleted': user.is_deleted,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
}
|
||||
)
|
||||
return me_user_public(user)
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands import SetPhoneCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
|
||||
from src.application.commands import SetPhoneCommand, SetAvatarCommand
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators import require_access_token
|
||||
from src.presentation.dependencies import (
|
||||
get_set_avatar_command,
|
||||
get_set_phone_command,
|
||||
get_set_encrypted_mnemonic_start_command,
|
||||
get_set_encrypted_mnemonic_complete_command,
|
||||
get_update_bank_details_start_command,
|
||||
get_update_bank_details_complete_command,
|
||||
get_change_password_start_command,
|
||||
get_change_password_complete_command,
|
||||
get_change_email_start_command,
|
||||
get_change_email_confirm_old_command,
|
||||
get_change_email_complete_command,
|
||||
)
|
||||
from src.presentation.schemas import SetPhoneRequest, EncryptedMnemonicConfirmRequest, BankConfirmRequest, ChangePasswordConfirmRequest, ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
|
||||
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import SetAvatarPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
|
||||
account_settings_router = APIRouter(prefix='/settings')
|
||||
|
||||
|
||||
_SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
'description': 'Битый или неподдерживаемый формат изображения, либо Base64 ошибочный.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Тело запроса не соответствует схеме (например, неверный Base64 или превышен размер).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'S3 не сконфигурирован, ошибка записи в хранилище или временная недоступность сервиса.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def set_phone(
|
||||
request: Request,
|
||||
@@ -33,118 +56,143 @@ async def set_phone(
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone})
|
||||
|
||||
|
||||
@account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def encrypted_mnemonic_start(
|
||||
@account_settings_router.patch(
|
||||
path='/avatar',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=SetAvatarPublicResponse,
|
||||
summary='Обновить аватар',
|
||||
description=(
|
||||
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль.'
|
||||
),
|
||||
response_description=(
|
||||
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
||||
),
|
||||
responses=_SET_AVATAR_ERROR_RESPONSES,
|
||||
)
|
||||
async def set_avatar(
|
||||
request: Request,
|
||||
body: SetAvatarRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id)
|
||||
return {'success': result}
|
||||
command: SetAvatarCommand = Depends(get_set_avatar_command),
|
||||
) -> SetAvatarPublicResponse:
|
||||
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
|
||||
pub = me_user_public(user)
|
||||
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
|
||||
|
||||
|
||||
@account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def encrypted_mnemonic_complete(
|
||||
request: Request,
|
||||
body: EncryptedMnemonicConfirmRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
|
||||
):
|
||||
user = await command(
|
||||
user_id=auth.user_id,
|
||||
code=body.code,
|
||||
encrypted_mnemonic=body.encrypted_mnemonic,
|
||||
)
|
||||
return {'encrypted_mnemonic': user.encrypted_mnemonic}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def change_email_start(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def change_email_confirm_old(
|
||||
request: Request,
|
||||
body: ChangeEmailConfirmOldRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def change_email_complete(
|
||||
request: Request,
|
||||
body: ChangeEmailCompleteRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id, code=body.code)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def change_password_start(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def change_password_complete(
|
||||
request: Request,
|
||||
body: ChangePasswordConfirmRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
|
||||
):
|
||||
result = await command(
|
||||
user_id=auth.user_id,
|
||||
code=body.code,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def bank_details_start(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
|
||||
):
|
||||
result = await command(user_id=auth.user_id)
|
||||
return {'success': result}
|
||||
|
||||
|
||||
@account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def bank_details_complete(
|
||||
request: Request,
|
||||
body: BankConfirmRequest,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
|
||||
):
|
||||
user = await command(
|
||||
user_id=auth.user_id,
|
||||
code=body.code,
|
||||
passport_data=body.passport_data,
|
||||
inn=body.inn,
|
||||
erc20=body.erc20,
|
||||
)
|
||||
return ORJSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
'passport_data': user.passport_data,
|
||||
'inn': user.inn,
|
||||
'erc20': user.erc20,
|
||||
},
|
||||
)
|
||||
#
|
||||
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def encrypted_mnemonic_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def encrypted_mnemonic_complete(
|
||||
# request: Request,
|
||||
# body: EncryptedMnemonicConfirmRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
|
||||
# ):
|
||||
# user = await command(
|
||||
# user_id=auth.user_id,
|
||||
# code=body.code,
|
||||
# encrypted_mnemonic=body.encrypted_mnemonic,
|
||||
# )
|
||||
# return {'encrypted_mnemonic': user.encrypted_mnemonic}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_confirm_old(
|
||||
# request: Request,
|
||||
# body: ChangeEmailConfirmOldRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_email_complete(
|
||||
# request: Request,
|
||||
# body: ChangeEmailCompleteRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id, code=body.code)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_password_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def change_password_complete(
|
||||
# request: Request,
|
||||
# body: ChangePasswordConfirmRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
|
||||
# ):
|
||||
# result = await command(
|
||||
# user_id=auth.user_id,
|
||||
# code=body.code,
|
||||
# new_password=body.new_password,
|
||||
# confirm_password=body.confirm_password,
|
||||
# )
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def bank_details_start(
|
||||
# request: Request,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
|
||||
# ):
|
||||
# result = await command(user_id=auth.user_id)
|
||||
# return {'success': result}
|
||||
#
|
||||
#
|
||||
# @account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
# async def bank_details_complete(
|
||||
# request: Request,
|
||||
# body: BankConfirmRequest,
|
||||
# auth: AuthContext = Depends(require_access_token),
|
||||
# command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
|
||||
# ):
|
||||
# user = await command(
|
||||
# user_id=auth.user_id,
|
||||
# code=body.code,
|
||||
# passport_data=body.passport_data,
|
||||
# inn=body.inn,
|
||||
# erc20=body.erc20,
|
||||
# )
|
||||
# return ORJSONResponse(
|
||||
# status_code=status.HTTP_200_OK,
|
||||
# content={
|
||||
# 'passport_data': user.passport_data,
|
||||
# 'inn': user.inn,
|
||||
# 'erc20': user.erc20,
|
||||
# },
|
||||
# )
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
|
||||
|
||||
19
src/presentation/schemas/api_errors.py
Normal file
19
src/presentation/schemas/api_errors.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApiErrorPayload(BaseModel):
|
||||
detail: str = Field(description='Текстовое описание ошибки для клиента')
|
||||
|
||||
|
||||
class ValidationErrorDetailItem(BaseModel):
|
||||
loc: list[str | int] = Field(description='Путь к полю, вызвавшему ошибку')
|
||||
msg: str = Field(description='Сообщение')
|
||||
type: str = Field(description='Тип ошибки валидации')
|
||||
|
||||
|
||||
class ApiValidationErrorsPayload(BaseModel):
|
||||
detail: list[ValidationErrorDetailItem] = Field(
|
||||
description='Список ошибок валидации тела или параметров запроса'
|
||||
)
|
||||
69
src/presentation/schemas/avatar.py
Normal file
69
src/presentation/schemas/avatar.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
AVATAR_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
_IMAGE_SIGNATURES = (
|
||||
b'\xff\xd8\xff',
|
||||
b'\x89PNG\r\n\x1a\n',
|
||||
b'GIF87a',
|
||||
b'GIF89a',
|
||||
)
|
||||
|
||||
|
||||
def _avatar_payload_to_bytes(photo_base64: str) -> bytes:
|
||||
s = photo_base64.strip()
|
||||
if not s:
|
||||
raise ValueError('photo_base64 must not be empty')
|
||||
if s.startswith('data:'):
|
||||
parts = s.split(',', 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError('Invalid data URL')
|
||||
s = parts[1].strip()
|
||||
try:
|
||||
data = base64.b64decode(s, validate=True)
|
||||
except binascii.Error as exc:
|
||||
raise ValueError('Invalid base64') from exc
|
||||
if len(data) > AVATAR_MAX_BYTES:
|
||||
raise ValueError(f'Photo must not exceed {AVATAR_MAX_BYTES} bytes')
|
||||
if len(data) < 12:
|
||||
raise ValueError('Photo data is too small')
|
||||
ok = False
|
||||
for sig in _IMAGE_SIGNATURES:
|
||||
if data.startswith(sig):
|
||||
ok = True
|
||||
break
|
||||
if not ok and data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
ok = True
|
||||
if not ok:
|
||||
raise ValueError('Photo must be JPEG, PNG, GIF or WebP')
|
||||
return data
|
||||
|
||||
|
||||
class SetAvatarRequest(BaseModel):
|
||||
photo_base64: str = Field(
|
||||
...,
|
||||
description='Изображение JPEG, PNG, GIF или WebP в Base64; допустим data URL (data:image/...;base64,...). Максимум 10 МБ после декодирования.',
|
||||
)
|
||||
decoded_bytes: bytes = Field(exclude=True)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def _decode_input(cls, data: Any):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
raw = data.get('photo_base64')
|
||||
if raw is None:
|
||||
return data
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError('photo_base64 must be a string')
|
||||
stripped = raw.strip()
|
||||
decoded = _avatar_payload_to_bytes(stripped)
|
||||
return {'photo_base64': stripped, 'decoded_bytes': decoded}
|
||||
59
src/presentation/schemas/me_public.py
Normal file
59
src/presentation/schemas/me_public.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class MeUserPublicResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=False)
|
||||
|
||||
id: str | None = Field(None, description='Идентификатор пользователя')
|
||||
email: str | None = Field(None, description='Email')
|
||||
first_name: str | None = Field(None, description='Имя')
|
||||
middle_name: str | None = Field(None, description='Отчество')
|
||||
last_name: str | None = Field(None, description='Фамилия')
|
||||
birth_date: date | None = Field(None, description='Дата рождения')
|
||||
encrypted_mnemonic: str | None = Field(None, description='Шифрованная мнемоника')
|
||||
phone: str | None = Field(None, description='Телефон')
|
||||
passport_data: str | None = Field(None, description='Паспортные данные')
|
||||
inn: str | None = Field(None, description='ИНН')
|
||||
erc20: str | None = Field(None, description='ERC-20 адрес')
|
||||
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
|
||||
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
|
||||
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
|
||||
created_at: datetime | None = Field(None, description='Время создания записи')
|
||||
updated_at: datetime | None = Field(None, description='Время последнего обновления')
|
||||
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at,
|
||||
)
|
||||
|
||||
|
||||
class SetAvatarPublicResponse(MeUserPublicResponse):
|
||||
webp_size_bytes: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description='Размер сохранённого файла аватара в формате WebP, байты',
|
||||
)
|
||||
1
src/presentation/serializers/__init__.py
Normal file
1
src/presentation/serializers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.serializers.me_user import me_user_payload, me_user_public
|
||||
13
src/presentation/serializers/me_user.py
Normal file
13
src/presentation/serializers/me_user.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
|
||||
|
||||
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
|
||||
return MeUserPublicResponse.from_user(user)
|
||||
|
||||
|
||||
def me_user_payload(user: UserEntity) -> dict:
|
||||
return me_user_public(user).model_dump(mode='json')
|
||||
Reference in New Issue
Block a user