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)