@@ -1,384 +0,0 @@
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,
# },
# )