feat: add avatars

This commit is contained in:
2026-05-14 23:46:26 +03:00
parent 20ddb196ff
commit d426b02d25
26 changed files with 857 additions and 162 deletions

View File

@@ -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

View File

@@ -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

View 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)

View File

@@ -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

View 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:
...

View File

@@ -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