feat: add delete avatar

This commit is contained in:
2026-05-17 14:54:28 +03:00
parent 6f6d10567e
commit d3b5e0c107
10 changed files with 180 additions and 6 deletions

View File

@@ -39,5 +39,5 @@ class IUserRepository(ABC):
raise NotImplementedError
@abstractmethod
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
raise NotImplementedError

View File

@@ -1,6 +1,7 @@
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.delete_avatar import DeleteAvatarCommand
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,37 @@
from __future__ import annotations
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.infrastructure.database.decorators import transactional
class DeleteAvatarCommand:
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) -> UserEntity:
prior = await self._unit_of_work.user_repository.get_user_by_id(user_id)
link = prior.avatar_link
if link:
key = self._s3.object_key_from_public_url(link)
if key:
try:
await self._s3.delete_object(key=key)
except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete avatar failed user_id={user_id} code={code}: {exc}')
user = await self._clear_avatar_link(user_id)
await self._cache.set_user(user_id, user)
self._logger.info(f'Avatar removed user_id={user_id}')
return user
@transactional
async def _clear_avatar_link(self, user_id: str) -> UserEntity:
return await self._unit_of_work.user_repository.set_avatar_link(user_id, None)

View File

@@ -6,6 +6,7 @@ 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
@@ -23,6 +24,8 @@ class SetAvatarCommand:
self._s3 = s3
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
prior = await self._unit_of_work.user_repository.get_user_by_id(user_id)
old_link = prior.avatar_link
try:
webp_bytes = image_bytes_to_webp(image_bytes)
except UnidentifiedImageError as exc:
@@ -46,6 +49,17 @@ class SetAvatarCommand:
user = await self._save_avatar_link(user_id, url)
await self._cache.set_user(user_id, user)
if old_link:
old_key = self._s3.object_key_from_public_url(old_link)
if old_key and old_key != object_key:
try:
await self._s3.delete_object(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}')
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
return user, len(webp_bytes)

View File

@@ -7,3 +7,11 @@ from typing import Protocol, runtime_checkable
class IS3(Protocol):
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
...
async def delete_object(self, *, key: str) -> None:
...
def object_key_from_public_url(self, url: str) -> str | None:
...

View File

@@ -89,7 +89,7 @@ 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:
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
return await self._update_field(user_id, avatar_link=avatar_link)
async def get_password_hash(self, user_id: str) -> str:

View File

@@ -24,6 +24,52 @@ class S3Service:
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
@staticmethod
def _url_prefix_variants(prefix: str) -> list[str]:
p = prefix.rstrip('/') + '/'
out = [p]
if p.startswith('https://'):
out.append('http://' + p[8:])
elif p.startswith('http://'):
out.append('https://' + p[7:])
return out
def _public_url_prefixes(self) -> list[str]:
acc: list[str] = []
pb = self._public_base_url
if pb:
acc.extend(self._url_prefix_variants(pb))
ep = self._endpoint_url
if ep:
base = f'{ep.rstrip("/")}/{self._bucket}'
acc.extend(self._url_prefix_variants(base))
if ep and self._use_reg_ru_website_public_host and 's3.regru.cloud' in ep.lower():
wh = f'https://{self._bucket}.website.regru.cloud'
acc.extend(self._url_prefix_variants(wh))
if not ep:
if self._region == 'us-east-1':
h = f'https://{self._bucket}.s3.amazonaws.com'
else:
h = f'https://{self._bucket}.s3.{self._region}.amazonaws.com'
acc.extend(self._url_prefix_variants(h))
seen: set[str] = set()
uniq: list[str] = []
for x in sorted(acc, key=len, reverse=True):
if x not in seen:
seen.add(x)
uniq.append(x)
return uniq
def object_key_from_public_url(self, url: str) -> str | None:
u = (url or '').strip()
if not u:
return None
for p in self._public_url_prefixes():
if u.startswith(p):
k = u[len(p):].split('?', 1)[0].split('#', 1)[0]
return k if k else None
return None
def _object_url(self, key: str) -> str:
if self._public_base_url:
return f'{self._public_base_url}/{key}'
@@ -62,3 +108,18 @@ class S3Service:
ContentType=content_type,
)
return self._object_url(key)
async def delete_object(self, *, key: str) -> None:
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.delete_object(Bucket=self._bucket, Key=key)

View File

@@ -2,6 +2,7 @@ from src.presentation.dependencies.commands import (
get_get_me_command,
get_set_phone_command,
get_set_avatar_command,
get_delete_avatar_command,
get_set_encrypted_mnemonic_start_command,
get_set_encrypted_mnemonic_complete_command,
get_update_bank_details_start_command,

View File

@@ -1,6 +1,6 @@
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, 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
@@ -35,6 +35,15 @@ def get_set_avatar_command(
return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
def get_delete_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),
) -> DeleteAvatarCommand:
return DeleteAvatarCommand(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),

View File

@@ -1,16 +1,17 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.application.commands import SetPhoneCommand, SetAvatarCommand
from src.application.commands import SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand
from src.application.domain.dto import AuthContext
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies import (
get_delete_avatar_command,
get_set_avatar_command,
get_set_phone_command,
)
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.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
from src.presentation.serializers import me_user_public
@@ -45,6 +46,26 @@ _SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
}
_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.',
'model': ApiErrorPayload,
},
status.HTTP_404_NOT_FOUND: {
'description': 'Учётная запись не найдена.',
'model': ApiErrorPayload,
},
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)
@csrf_protect()
async def set_phone(
@@ -65,6 +86,7 @@ async def set_phone(
summary='Обновить аватар',
description=(
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
),
response_description=(
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
@@ -82,6 +104,27 @@ async def set_avatar(
pub = me_user_public(user)
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
@account_settings_router.delete(
path='/avatar',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
response_model=MeUserPublicResponse,
summary='Удалить аватар',
description=(
'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.'
),
responses=_DELETE_AVATAR_ERROR_RESPONSES,
)
@csrf_protect()
async def delete_avatar(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: DeleteAvatarCommand = Depends(get_delete_avatar_command),
) -> MeUserPublicResponse:
user = await command(user_id=auth.user_id)
return me_user_public(user)
#
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def encrypted_mnemonic_start(