385 lines
16 KiB
Python
385 lines
16 KiB
Python
from fastapi import APIRouter, Depends, Request
|
||
from fastapi.responses import ORJSONResponse
|
||
from starlette import status
|
||
from src.application.commands import (
|
||
SetPhoneCommand,
|
||
SetAvatarCommand,
|
||
DeleteAvatarCommand,
|
||
ChangePasswordStartCommand,
|
||
ChangePasswordCompleteCommand,
|
||
ForgotPasswordStartCommand,
|
||
ForgotPasswordCompleteCommand,
|
||
)
|
||
from src.application.domain.dto import AuthContext
|
||
from src.presentation.decorators import require_access_token, csrf_protect, rate_limit, email_rl_key
|
||
from src.presentation.dependencies import (
|
||
get_delete_avatar_command,
|
||
get_set_avatar_command,
|
||
get_set_phone_command,
|
||
get_change_password_start_command,
|
||
get_change_password_complete_command,
|
||
get_forgot_password_start_command,
|
||
get_forgot_password_complete_command,
|
||
)
|
||
from src.presentation.schemas import (
|
||
SetAvatarRequest,
|
||
SetPhoneRequest,
|
||
ChangePasswordConfirmRequest,
|
||
ForgotPasswordStartRequest,
|
||
ForgotPasswordCompleteRequest,
|
||
)
|
||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||
from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
|
||
from src.presentation.serializers import me_user_public
|
||
|
||
|
||
account_settings_router = APIRouter(prefix='/settings')
|
||
|
||
|
||
_SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||
status.HTTP_400_BAD_REQUEST: {
|
||
'description': 'Битый или неподдерживаемый формат изображения, либо Base64 ошибочный.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_401_UNAUTHORIZED: {
|
||
'description': 'Не передан или неверен access token.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_404_NOT_FOUND: {
|
||
'description': 'Учётная запись не найдена.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||
'description': 'Тело запроса не соответствует схеме (например, неверный Base64 или превышен размер).',
|
||
'model': ApiValidationErrorsPayload,
|
||
},
|
||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||
'description': 'S3 не сконфигурирован, ошибка записи в хранилище или временная недоступность сервиса.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
}
|
||
|
||
|
||
_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||
status.HTTP_400_BAD_REQUEST: {
|
||
'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_401_UNAUTHORIZED: {
|
||
'description': 'Не передан или неверен access token.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_404_NOT_FOUND: {
|
||
'description': 'Учётная запись не найдена или у пользователя нет email.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||
'description': 'Тело запроса не соответствует схеме (код, длина пароля).',
|
||
'model': ApiValidationErrorsPayload,
|
||
},
|
||
status.HTTP_429_TOO_MANY_REQUESTS: {
|
||
'description': 'Код уже отправлен или слишком частые запросы.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||
'description': 'Внутренняя ошибка сервера.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
}
|
||
|
||
|
||
_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||
status.HTTP_400_BAD_REQUEST: {
|
||
'description': 'Неверный или просроченный код, пароли не совпадают.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||
'description': 'Тело запроса не соответствует схеме.',
|
||
'model': ApiValidationErrorsPayload,
|
||
},
|
||
status.HTTP_429_TOO_MANY_REQUESTS: {
|
||
'description': 'Код уже отправлен или слишком частые запросы.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||
'description': 'Внутренняя ошибка сервера.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
|
||
'model': ApiErrorPayload,
|
||
},
|
||
}
|
||
|
||
|
||
_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(
|
||
request: Request,
|
||
body: SetPhoneRequest,
|
||
auth: AuthContext = Depends(require_access_token),
|
||
command: SetPhoneCommand = Depends(get_set_phone_command),
|
||
):
|
||
user = await command(user_id=auth.user_id, phone=body.phone)
|
||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'phone': user.phone})
|
||
|
||
|
||
@account_settings_router.patch(
|
||
path='/avatar',
|
||
response_class=ORJSONResponse,
|
||
status_code=status.HTTP_200_OK,
|
||
response_model=SetAvatarPublicResponse,
|
||
summary='Обновить аватар',
|
||
description=(
|
||
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
|
||
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
|
||
),
|
||
response_description=(
|
||
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
||
),
|
||
responses=_SET_AVATAR_ERROR_RESPONSES,
|
||
)
|
||
@csrf_protect()
|
||
async def set_avatar(
|
||
request: Request,
|
||
body: SetAvatarRequest,
|
||
auth: AuthContext = Depends(require_access_token),
|
||
command: SetAvatarCommand = Depends(get_set_avatar_command),
|
||
) -> SetAvatarPublicResponse:
|
||
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
|
||
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='/password/start',
|
||
response_class=ORJSONResponse,
|
||
status_code=status.HTTP_200_OK,
|
||
summary='Запросить код для смены пароля',
|
||
description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.',
|
||
responses=_PASSWORD_ERROR_RESPONSES,
|
||
)
|
||
@csrf_protect()
|
||
async def change_password_start(
|
||
request: Request,
|
||
auth: AuthContext = Depends(require_access_token),
|
||
command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
|
||
):
|
||
result = await command(user_id=auth.user_id)
|
||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||
|
||
|
||
@account_settings_router.post(
|
||
path='/password/complete',
|
||
response_class=ORJSONResponse,
|
||
status_code=status.HTTP_200_OK,
|
||
summary='Подтвердить смену пароля',
|
||
description=(
|
||
'Принимает код из письма, новый пароль и его подтверждение. '
|
||
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
|
||
),
|
||
responses=_PASSWORD_ERROR_RESPONSES,
|
||
)
|
||
@csrf_protect()
|
||
async def change_password_complete(
|
||
request: Request,
|
||
body: ChangePasswordConfirmRequest,
|
||
auth: AuthContext = Depends(require_access_token),
|
||
command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
|
||
):
|
||
result = await command(
|
||
user_id=auth.user_id,
|
||
code=body.code,
|
||
new_password=body.new_password,
|
||
confirm_password=body.confirm_password,
|
||
)
|
||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||
|
||
|
||
@account_settings_router.post(
|
||
path='/password/forgot/start',
|
||
response_class=ORJSONResponse,
|
||
status_code=status.HTTP_200_OK,
|
||
summary='Запросить код для восстановления пароля',
|
||
description=(
|
||
'Принимает email. Если учётная запись существует, отправляет шестизначный код. '
|
||
'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).'
|
||
),
|
||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||
)
|
||
@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||
async def forgot_password_start(
|
||
request: Request,
|
||
body: ForgotPasswordStartRequest,
|
||
command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command),
|
||
):
|
||
result = await command(email=body.email)
|
||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||
|
||
|
||
@account_settings_router.post(
|
||
path='/password/forgot/complete',
|
||
response_class=ORJSONResponse,
|
||
status_code=status.HTTP_200_OK,
|
||
summary='Установить новый пароль по коду из письма',
|
||
description=(
|
||
'Принимает email, код из письма, новый пароль и подтверждение. '
|
||
'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.'
|
||
),
|
||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||
)
|
||
@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||
async def forgot_password_complete(
|
||
request: Request,
|
||
body: ForgotPasswordCompleteRequest,
|
||
command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command),
|
||
):
|
||
result = await command(
|
||
email=body.email,
|
||
code=body.code,
|
||
new_password=body.new_password,
|
||
confirm_password=body.confirm_password,
|
||
)
|
||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||
|
||
|
||
#
|
||
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def encrypted_mnemonic_start(
|
||
# request: Request,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: SetEncryptedMnemonicStartCommand = Depends(get_set_encrypted_mnemonic_start_command),
|
||
# ):
|
||
# result = await command(user_id=auth.user_id)
|
||
# return {'success': result}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/encrypted-mnemonic/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def encrypted_mnemonic_complete(
|
||
# request: Request,
|
||
# body: EncryptedMnemonicConfirmRequest,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: SetEncryptedMnemonicCompleteCommand = Depends(get_set_encrypted_mnemonic_complete_command),
|
||
# ):
|
||
# user = await command(
|
||
# user_id=auth.user_id,
|
||
# code=body.code,
|
||
# encrypted_mnemonic=body.encrypted_mnemonic,
|
||
# )
|
||
# return {'encrypted_mnemonic': user.encrypted_mnemonic}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/email/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def change_email_start(
|
||
# request: Request,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: ChangeEmailStartCommand = Depends(get_change_email_start_command),
|
||
# ):
|
||
# result = await command(user_id=auth.user_id)
|
||
# return {'success': result}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/email/confirm-old', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def change_email_confirm_old(
|
||
# request: Request,
|
||
# body: ChangeEmailConfirmOldRequest,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: ChangeEmailConfirmOldCommand = Depends(get_change_email_confirm_old_command),
|
||
# ):
|
||
# result = await command(user_id=auth.user_id, code=body.code, new_email=body.new_email)
|
||
# return {'success': result}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/email/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def change_email_complete(
|
||
# request: Request,
|
||
# body: ChangeEmailCompleteRequest,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: ChangeEmailCompleteCommand = Depends(get_change_email_complete_command),
|
||
# ):
|
||
# result = await command(user_id=auth.user_id, code=body.code)
|
||
# return {'success': result}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def bank_details_start(
|
||
# request: Request,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: UpdateBankDetailsStartCommand = Depends(get_update_bank_details_start_command),
|
||
# ):
|
||
# result = await command(user_id=auth.user_id)
|
||
# return {'success': result}
|
||
#
|
||
#
|
||
# @account_settings_router.post(path='/bank/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||
# async def bank_details_complete(
|
||
# request: Request,
|
||
# body: BankConfirmRequest,
|
||
# auth: AuthContext = Depends(require_access_token),
|
||
# command: UpdateBankDetailsCompleteCommand = Depends(get_update_bank_details_complete_command),
|
||
# ):
|
||
# user = await command(
|
||
# user_id=auth.user_id,
|
||
# code=body.code,
|
||
# passport_data=body.passport_data,
|
||
# inn=body.inn,
|
||
# erc20=body.erc20,
|
||
# )
|
||
# return ORJSONResponse(
|
||
# status_code=status.HTTP_200_OK,
|
||
# content={
|
||
# 'passport_data': user.passport_data,
|
||
# 'inn': user.inn,
|
||
# 'erc20': user.erc20,
|
||
# },
|
||
# )
|