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, # }, # )