Files
users/src/application/commands/set_avatar.py
2026-05-17 15:36:53 +03:00

101 lines
4.7 KiB
Python

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
@transactional
async def _load_user(self, user_id: str) -> UserEntity:
user = await self._unit_of_work.user_repository.get_user_by_id(user_id)
self._logger.debug(f'Avatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}')
return user
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
prior = await self._load_user(user_id)
old_link = prior.avatar_link
self._logger.info(
f'SetAvatar start user_id={user_id} input_bytes={len(image_bytes)} had_previous_link={bool(old_link)}'
)
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
self._logger.debug(f'SetAvatar webp_ready bytes={len(webp_bytes)}')
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
self._logger.info(f'SetAvatar S3 upload start user_id={user_id} key={object_key} webp_bytes={len(webp_bytes)}')
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
self._logger.info(f'SetAvatar S3 upload done user_id={user_id} key={object_key} public_url_len={len(url)}')
user = await self._save_avatar_link(user_id, url)
self._logger.info(
f'SetAvatar DB updated user_id={user_id} key={object_key} '
f'entity_avatar_link_len={len(user.avatar_link or "")}'
)
await self._cache.set_user(user_id, user)
self._logger.debug(f'SetAvatar cache updated user_id={user_id}')
if old_link:
old_key = self._s3.object_key_from_public_url(old_link)
if not old_key:
self._logger.warning(
f'SetAvatar could not parse old avatar URL for S3 delete user_id={user_id} '
f'old_link_len={len(old_link)}'
)
elif old_key == object_key:
self._logger.debug(f'SetAvatar skip delete same object key user_id={user_id} key={object_key}')
else:
self._logger.info(f'SetAvatar S3 delete old object user_id={user_id} old_key={old_key}')
try:
await self._s3.delete_object(key=old_key)
self._logger.info(f'SetAvatar S3 old object removed user_id={user_id} old_key={old_key}')
except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}')
else:
self._logger.debug(f'SetAvatar old object already gone user_id={user_id} code={code}')
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:
self._logger.debug(f'SetAvatar DB transaction set_avatar_link user_id={user_id} link_len={len(avatar_link)}')
return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link)