feat: add delete avatar
This commit is contained in:
@@ -39,5 +39,5 @@ class IUserRepository(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from src.application.commands.get_me import GetMeCommand
|
from src.application.commands.get_me import GetMeCommand
|
||||||
from src.application.commands.set_phone import SetPhoneCommand
|
from src.application.commands.set_phone import SetPhoneCommand
|
||||||
from src.application.commands.set_avatar import SetAvatarCommand
|
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_start import SetEncryptedMnemonicStartCommand
|
||||||
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
|
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
|
||||||
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand
|
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand
|
||||||
|
|||||||
37
src/application/commands/delete_avatar.py
Normal file
37
src/application/commands/delete_avatar.py
Normal 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)
|
||||||
@@ -6,6 +6,7 @@ from PIL import UnidentifiedImageError
|
|||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import ICache, ILogger, IS3
|
from src.application.contracts import ICache, ILogger, IS3
|
||||||
from src.application.domain.entities import UserEntity
|
from src.application.domain.entities import UserEntity
|
||||||
@@ -23,6 +24,8 @@ class SetAvatarCommand:
|
|||||||
self._s3 = s3
|
self._s3 = s3
|
||||||
|
|
||||||
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
|
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:
|
try:
|
||||||
webp_bytes = image_bytes_to_webp(image_bytes)
|
webp_bytes = image_bytes_to_webp(image_bytes)
|
||||||
except UnidentifiedImageError as exc:
|
except UnidentifiedImageError as exc:
|
||||||
@@ -46,6 +49,17 @@ class SetAvatarCommand:
|
|||||||
|
|
||||||
user = await self._save_avatar_link(user_id, url)
|
user = await self._save_avatar_link(user_id, url)
|
||||||
await self._cache.set_user(user_id, user)
|
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}')
|
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
|
||||||
return user, len(webp_bytes)
|
return user, len(webp_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,11 @@ from typing import Protocol, runtime_checkable
|
|||||||
class IS3(Protocol):
|
class IS3(Protocol):
|
||||||
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
|
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:
|
||||||
|
...
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class UserRepository(IUserRepository):
|
|||||||
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
|
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
|
||||||
return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic)
|
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)
|
return await self._update_field(user_id, avatar_link=avatar_link)
|
||||||
|
|
||||||
async def get_password_hash(self, user_id: str) -> str:
|
async def get_password_hash(self, user_id: str) -> str:
|
||||||
|
|||||||
@@ -24,6 +24,52 @@ class S3Service:
|
|||||||
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() 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
|
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:
|
def _object_url(self, key: str) -> str:
|
||||||
if self._public_base_url:
|
if self._public_base_url:
|
||||||
return f'{self._public_base_url}/{key}'
|
return f'{self._public_base_url}/{key}'
|
||||||
@@ -62,3 +108,18 @@ class S3Service:
|
|||||||
ContentType=content_type,
|
ContentType=content_type,
|
||||||
)
|
)
|
||||||
return self._object_url(key)
|
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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from src.presentation.dependencies.commands import (
|
|||||||
get_get_me_command,
|
get_get_me_command,
|
||||||
get_set_phone_command,
|
get_set_phone_command,
|
||||||
get_set_avatar_command,
|
get_set_avatar_command,
|
||||||
|
get_delete_avatar_command,
|
||||||
get_set_encrypted_mnemonic_start_command,
|
get_set_encrypted_mnemonic_start_command,
|
||||||
get_set_encrypted_mnemonic_complete_command,
|
get_set_encrypted_mnemonic_complete_command,
|
||||||
get_update_bank_details_start_command,
|
get_update_bank_details_start_command,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from src.application.abstractions import IUnitOfWork
|
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.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
|
||||||
from src.presentation.dependencies.cache import get_cache
|
from src.presentation.dependencies.cache import get_cache
|
||||||
from src.presentation.dependencies.logger import get_logger
|
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)
|
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(
|
def get_set_encrypted_mnemonic_start_command(
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from starlette import status
|
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.application.domain.dto import AuthContext
|
||||||
from src.presentation.decorators import require_access_token, csrf_protect
|
from src.presentation.decorators import require_access_token, csrf_protect
|
||||||
from src.presentation.dependencies import (
|
from src.presentation.dependencies import (
|
||||||
|
get_delete_avatar_command,
|
||||||
get_set_avatar_command,
|
get_set_avatar_command,
|
||||||
get_set_phone_command,
|
get_set_phone_command,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
|
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
|
||||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
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
|
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)
|
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||||
@csrf_protect()
|
@csrf_protect()
|
||||||
async def set_phone(
|
async def set_phone(
|
||||||
@@ -65,6 +86,7 @@ async def set_phone(
|
|||||||
summary='Обновить аватар',
|
summary='Обновить аватар',
|
||||||
description=(
|
description=(
|
||||||
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
|
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
|
||||||
|
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
|
||||||
),
|
),
|
||||||
response_description=(
|
response_description=(
|
||||||
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
||||||
@@ -82,6 +104,27 @@ async def set_avatar(
|
|||||||
pub = me_user_public(user)
|
pub = me_user_public(user)
|
||||||
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
|
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)
|
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||||
# async def encrypted_mnemonic_start(
|
# async def encrypted_mnemonic_start(
|
||||||
|
|||||||
Reference in New Issue
Block a user