Files
users/src/presentation/routing/account_settings.py
2026-05-19 15:26:50 +03:00

385 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
# },
# )