fix: update
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
from typing import Any, Awaitable, Callable
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies.cache import get_redis
|
||||
|
||||
|
||||
def cached(*, prefix: str) -> Callable:
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
logger = get_logger()
|
||||
|
||||
request = kwargs.get('request')
|
||||
if not isinstance(request, Request):
|
||||
for a in args:
|
||||
if isinstance(a, Request):
|
||||
request = a
|
||||
break
|
||||
|
||||
auth = kwargs.get('auth')
|
||||
user_id = getattr(auth, 'user_id', None) if auth else None
|
||||
|
||||
if request is None or user_id is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
cache_key = f'{prefix}:{user_id}'
|
||||
|
||||
try:
|
||||
redis = get_redis(request)
|
||||
cache = KeydbCache(redis)
|
||||
hit = await cache.get_user(user_id)
|
||||
if hit is not None:
|
||||
logger.debug(f'Cache hit key={cache_key}')
|
||||
return ORJSONResponse(status_code=200, content=hit)
|
||||
except Exception as e:
|
||||
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -1,171 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
import inspect
|
||||
import hashlib
|
||||
from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable
|
||||
from fastapi import Request
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies import get_redis
|
||||
|
||||
|
||||
def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request:
|
||||
req = kwargs.get('request')
|
||||
if isinstance(req, Request):
|
||||
return req
|
||||
for a in args:
|
||||
if isinstance(a, Request):
|
||||
return a
|
||||
raise RuntimeError('rate_limit decorator requires fastapi.Request argument')
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
xff = request.headers.get('x-forwarded-for')
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return 'unknown'
|
||||
|
||||
|
||||
_LUA_INCR_EXPIRE_TTL = '''
|
||||
local key = KEYS[1]
|
||||
local window = tonumber(ARGV[1])
|
||||
|
||||
local current = redis.call('INCR', key)
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', key, window)
|
||||
end
|
||||
|
||||
local ttl = redis.call('TTL', key)
|
||||
return { current, ttl }
|
||||
'''
|
||||
|
||||
|
||||
Scope = Literal['ip', 'device', 'user', 'key']
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KeyBuilder1(Protocol):
|
||||
def __call__(self, request: Request) -> str: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KeyBuilder3(Protocol):
|
||||
def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ...
|
||||
|
||||
|
||||
KeyBuilder = KeyBuilder1 | KeyBuilder3
|
||||
|
||||
|
||||
def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
||||
try:
|
||||
sig = inspect.signature(builder)
|
||||
if len(sig.parameters) >= 3:
|
||||
return builder(request, args, kwargs)
|
||||
return builder(request)
|
||||
except Exception as e:
|
||||
try:
|
||||
return builder(request, args, kwargs)
|
||||
except Exception:
|
||||
raise e
|
||||
|
||||
def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
||||
|
||||
body = kwargs.get('body')
|
||||
if body is None and args:
|
||||
for a in args:
|
||||
if hasattr(a, 'email'):
|
||||
body = a
|
||||
break
|
||||
|
||||
email = (getattr(body, 'email', '') or '').strip().lower()
|
||||
if not email:
|
||||
email = _client_ip(request)
|
||||
|
||||
digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24]
|
||||
return f'email:{digest}'
|
||||
|
||||
def rate_limit(
|
||||
*,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
scope: Scope = 'ip',
|
||||
key_prefix: str = 'rl',
|
||||
key_builder: Optional[KeyBuilder] = None,
|
||||
fail_open: bool = True,
|
||||
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
||||
|
||||
if limit <= 0:
|
||||
raise ValueError('rate_limit: limit must be > 0')
|
||||
if window_seconds <= 0:
|
||||
raise ValueError('rate_limit: window_seconds must be > 0')
|
||||
if scope == 'key' and not key_builder:
|
||||
raise ValueError('rate_limit: scope="key" requires key_builder')
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any):
|
||||
request = _find_request(args, kwargs)
|
||||
logger: ILogger = get_logger()
|
||||
|
||||
if scope == 'ip':
|
||||
ident = _client_ip(request)
|
||||
elif scope == 'device':
|
||||
ident = request.cookies.get('device_id') or _client_ip(request)
|
||||
elif scope == 'user':
|
||||
user = getattr(request.state, 'user', None)
|
||||
user_id = getattr(user, 'id', None) if user else None
|
||||
ident = str(user_id) if user_id else _client_ip(request)
|
||||
else:
|
||||
try:
|
||||
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.error(f'RateLimit key_builder failed error={str(e)}')
|
||||
raise ApplicationException(500, 'Rate limiter key_builder failed')
|
||||
|
||||
route = request.url.path
|
||||
method = request.method
|
||||
redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}'
|
||||
|
||||
logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}')
|
||||
|
||||
try:
|
||||
redis: Redis = get_redis(request)
|
||||
|
||||
result = await redis.eval(
|
||||
_LUA_INCR_EXPIRE_TTL,
|
||||
1,
|
||||
redis_key,
|
||||
str(window_seconds),
|
||||
)
|
||||
|
||||
count = int(result[0])
|
||||
ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds
|
||||
ttl = window_seconds if ttl_raw < 0 else ttl_raw
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}')
|
||||
|
||||
if fail_open:
|
||||
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise ApplicationException(503, 'Rate limiter unavailable')
|
||||
|
||||
if count > limit:
|
||||
retry_after = max(ttl, 0)
|
||||
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
||||
raise ApplicationException(
|
||||
status_code=429,
|
||||
message='Too Many Requests',
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
logger.debug(f'RateLimit passed key={redis_key} count={count}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -4,4 +4,3 @@ from src.presentation.dependencies.commands import (
|
||||
get_list_purchase_requests_command,
|
||||
)
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
from src.presentation.dependencies.cache import get_redis, get_cache
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from fastapi import Depends, Request
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ICache
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
|
||||
|
||||
def get_redis(request: Request) -> Redis:
|
||||
return request.app.state.redis
|
||||
|
||||
|
||||
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
|
||||
return KeydbCache(redis_client)
|
||||
@@ -1,8 +0,0 @@
|
||||
from functools import lru_cache
|
||||
from src.application.contracts import IQueueMessanger
|
||||
from src.infrastructure.messanger import RabbitClient
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_rabbit() -> IQueueMessanger:
|
||||
return RabbitClient()
|
||||
@@ -1,32 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.contracts import IS3
|
||||
from src.application.domain.exceptions import ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.storage.s3_service import S3Service
|
||||
|
||||
_s3singleton: IS3 | None = None
|
||||
|
||||
|
||||
def get_s3_storage() -> IS3:
|
||||
global _s3singleton
|
||||
if _s3singleton is not None:
|
||||
return _s3singleton
|
||||
if not settings.S3_BUCKET.strip():
|
||||
raise ServiceUnavailableException(message='S3 is not configured')
|
||||
endpoint = settings.S3_ENDPOINT_URL.strip()
|
||||
pub = settings.S3_PUBLIC_BASE_URL.strip()
|
||||
if not pub and not endpoint:
|
||||
raise ServiceUnavailableException(message='Set S3_ENDPOINT_URL (or S3_PUBLIC_BASE_URL for a custom CDN base)')
|
||||
ak = settings.S3_ACCESS_KEY_ID.strip() or None
|
||||
sk = settings.S3_SECRET_ACCESS_KEY.strip() or None
|
||||
_s3singleton = S3Service(
|
||||
bucket=settings.S3_BUCKET.strip(),
|
||||
region=settings.S3_REGION.strip() or 'us-east-1',
|
||||
access_key_id=ak,
|
||||
secret_access_key=sk,
|
||||
public_base_url=pub if pub else None,
|
||||
endpoint_url=endpoint if endpoint else None,
|
||||
use_reg_ru_website_public_host=settings.S3_REGRU_PUBLIC_WEBSITE_HOST,
|
||||
)
|
||||
return _s3singleton
|
||||
@@ -1,22 +1,11 @@
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
from src.application.contracts import IJwtService, ILogger, IHashService
|
||||
from src.application.contracts import IJwtService, ILogger
|
||||
from src.infrastructure.security.jwt import JwtService
|
||||
from src.infrastructure.vault import JwtKeyStore
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _hash_service(logger: ILogger) -> IHashService:
|
||||
from src.infrastructure.security.hash import HashService
|
||||
|
||||
return HashService(logger=logger)
|
||||
|
||||
|
||||
def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService:
|
||||
return _hash_service(logger)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _jwt_service(logger: ILogger) -> IJwtService:
|
||||
key_store = JwtKeyStore.get_instance()
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators import require_access_token
|
||||
from src.presentation.dependencies.commands import get_get_me_command
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.decorators import csrf_protect
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
account_router = APIRouter()
|
||||
|
||||
|
||||
@account_router.get(
|
||||
path='/',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=MeUserPublicResponse,
|
||||
summary='Текущий пользователь',
|
||||
description=(
|
||||
'Возвращает профиль, в том числе поле avatar_link — публичный HTTPS URL '
|
||||
'текущего аватара (WebP в объектном хранилище), либо null, если аватар не задан. '
|
||||
'Изображение по этому URL отдаёт не Users API, а хранилище; отдельного эндпоинта загрузки байтов аватара нет. '
|
||||
'Загрузить или сменить аватар можно через PATCH /me/settings/avatar. '
|
||||
'Защита CSRF: cookie csrf_token и заголовок X-CSRF-Token с тем же значением.'
|
||||
),
|
||||
responses={
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Ошибка валидации входных данных (например, заголовков).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
},
|
||||
)
|
||||
@csrf_protect()
|
||||
async def me(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetMeCommand = Depends(get_get_me_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
logger.info(f'Get user: {user.id}')
|
||||
return me_user_public(user)
|
||||
@@ -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,
|
||||
# },
|
||||
# )
|
||||
@@ -1,7 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
deals_router = APIRouter(prefix='/deals')
|
||||
|
||||
@deals_router.get(path='')
|
||||
async def deals():
|
||||
pass
|
||||
@@ -1,11 +0,0 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
|
||||
devices_router = APIRouter(prefix='/devices')
|
||||
|
||||
@devices_router.get(path='/', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def devices(
|
||||
request: Request,
|
||||
):
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
|
||||
from src.application.commands import (
|
||||
CreatePurchaseRequestCommand,
|
||||
@@ -7,6 +7,7 @@ from src.application.commands import (
|
||||
)
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
from src.presentation.decorators.csrf import csrf_protect
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_create_purchase_request_command,
|
||||
get_get_purchase_request_command,
|
||||
@@ -23,7 +24,9 @@ purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchas
|
||||
|
||||
|
||||
@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
@csrf_protect()
|
||||
async def create_purchase_request(
|
||||
request: Request,
|
||||
body: CreatePurchaseRequestBody,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command),
|
||||
@@ -39,7 +42,9 @@ async def create_purchase_request(
|
||||
|
||||
|
||||
@purchase_requests_router.get('', response_model=PurchaseRequestListResponse)
|
||||
@csrf_protect()
|
||||
async def list_purchase_requests(
|
||||
request: Request,
|
||||
status_filter: str | None = Query(default=None, alias='status'),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
@@ -59,7 +64,9 @@ async def list_purchase_requests(
|
||||
|
||||
|
||||
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
|
||||
@csrf_protect()
|
||||
async def get_purchase_request(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
|
||||
from src.presentation.schemas.password import (
|
||||
ChangePasswordConfirmRequest,
|
||||
ForgotPasswordStartRequest,
|
||||
ForgotPasswordCompleteRequest,
|
||||
)
|
||||
from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
|
||||
@@ -1,19 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApiErrorPayload(BaseModel):
|
||||
detail: str = Field(description='Текстовое описание ошибки для клиента')
|
||||
|
||||
|
||||
class ValidationErrorDetailItem(BaseModel):
|
||||
loc: list[str | int] = Field(description='Путь к полю, вызвавшему ошибку')
|
||||
msg: str = Field(description='Сообщение')
|
||||
type: str = Field(description='Тип ошибки валидации')
|
||||
|
||||
|
||||
class ApiValidationErrorsPayload(BaseModel):
|
||||
detail: list[ValidationErrorDetailItem] = Field(
|
||||
description='Список ошибок валидации тела или параметров запроса'
|
||||
)
|
||||
@@ -1,69 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
AVATAR_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
_IMAGE_SIGNATURES = (
|
||||
b'\xff\xd8\xff',
|
||||
b'\x89PNG\r\n\x1a\n',
|
||||
b'GIF87a',
|
||||
b'GIF89a',
|
||||
)
|
||||
|
||||
|
||||
def _avatar_payload_to_bytes(photo_base64: str) -> bytes:
|
||||
s = photo_base64.strip()
|
||||
if not s:
|
||||
raise ValueError('photo_base64 must not be empty')
|
||||
if s.startswith('data:'):
|
||||
parts = s.split(',', 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError('Invalid data URL')
|
||||
s = parts[1].strip()
|
||||
try:
|
||||
data = base64.b64decode(s, validate=True)
|
||||
except binascii.Error as exc:
|
||||
raise ValueError('Invalid base64') from exc
|
||||
if len(data) > AVATAR_MAX_BYTES:
|
||||
raise ValueError(f'Photo must not exceed {AVATAR_MAX_BYTES} bytes')
|
||||
if len(data) < 12:
|
||||
raise ValueError('Photo data is too small')
|
||||
ok = False
|
||||
for sig in _IMAGE_SIGNATURES:
|
||||
if data.startswith(sig):
|
||||
ok = True
|
||||
break
|
||||
if not ok and data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
ok = True
|
||||
if not ok:
|
||||
raise ValueError('Photo must be JPEG, PNG, GIF or WebP')
|
||||
return data
|
||||
|
||||
|
||||
class SetAvatarRequest(BaseModel):
|
||||
photo_base64: str = Field(
|
||||
...,
|
||||
description='Изображение JPEG, PNG, GIF или WebP в Base64; допустим data URL (data:image/...;base64,...). Максимум 10 МБ после декодирования.',
|
||||
)
|
||||
decoded_bytes: bytes = Field(exclude=True)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def _decode_input(cls, data: Any):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
raw = data.get('photo_base64')
|
||||
if raw is None:
|
||||
return data
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError('photo_base64 must be a string')
|
||||
stripped = raw.strip()
|
||||
decoded = _avatar_payload_to_bytes(stripped)
|
||||
return {'photo_base64': stripped, 'decoded_bytes': decoded}
|
||||
@@ -1,116 +0,0 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
|
||||
class BankUpdateRequest(BaseModel):
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def at_least_one(self) -> Self:
|
||||
if not any([self.passport_data, self.inn, self.erc20]):
|
||||
raise ValueError('At least one field is required')
|
||||
return self
|
||||
|
||||
@field_validator('passport_data', 'inn', 'erc20')
|
||||
@classmethod
|
||||
def strip_optional(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip()
|
||||
return s or None
|
||||
|
||||
@field_validator('inn')
|
||||
@classmethod
|
||||
def validate_inn(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{10}(\d{2})?$', v):
|
||||
raise ValueError('INN must be 10 or 12 digits')
|
||||
if len(v) > 12:
|
||||
raise ValueError('INN is too long')
|
||||
return v
|
||||
|
||||
@field_validator('erc20')
|
||||
@classmethod
|
||||
def validate_erc20(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^0x[a-fA-F0-9]{40}$', v):
|
||||
raise ValueError('ERC20 address must be 0x followed by 40 hex characters')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data')
|
||||
@classmethod
|
||||
def validate_passport_data(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if len(v) > 255:
|
||||
raise ValueError('Passport data is too long')
|
||||
return v
|
||||
|
||||
|
||||
class BankConfirmRequest(BaseModel):
|
||||
code: str
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def at_least_one_field(self) -> Self:
|
||||
if not any([self.passport_data, self.inn, self.erc20]):
|
||||
raise ValueError('At least one field is required')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data', 'inn', 'erc20')
|
||||
@classmethod
|
||||
def strip_optional(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip()
|
||||
return s or None
|
||||
|
||||
@field_validator('inn')
|
||||
@classmethod
|
||||
def validate_inn(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{10}(\d{2})?$', v):
|
||||
raise ValueError('INN must be 10 or 12 digits')
|
||||
if len(v) > 12:
|
||||
raise ValueError('INN is too long')
|
||||
return v
|
||||
|
||||
@field_validator('erc20')
|
||||
@classmethod
|
||||
def validate_erc20(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if not re.match(r'^0x[a-fA-F0-9]{40}$', v):
|
||||
raise ValueError('ERC20 address must be 0x followed by 40 hex characters')
|
||||
return v
|
||||
|
||||
@field_validator('passport_data')
|
||||
@classmethod
|
||||
def validate_passport_data(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
if len(v) > 255:
|
||||
raise ValueError('Passport data is too long')
|
||||
return v
|
||||
@@ -1,35 +0,0 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class ChangeEmailConfirmOldRequest(BaseModel):
|
||||
code: str
|
||||
new_email: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_email')
|
||||
@classmethod
|
||||
def validate_new_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ChangeEmailCompleteRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
@@ -1,23 +0,0 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class EncryptedMnemonicConfirmRequest(BaseModel):
|
||||
code: str
|
||||
encrypted_mnemonic: str
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('encrypted_mnemonic')
|
||||
@classmethod
|
||||
def validate_encrypted_mnemonic(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError('encrypted_mnemonic must not be empty')
|
||||
return v
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class MeUserPublicResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=False)
|
||||
|
||||
id: str | None = Field(None, description='Идентификатор пользователя')
|
||||
email: str | None = Field(None, description='Email')
|
||||
first_name: str | None = Field(None, description='Имя')
|
||||
middle_name: str | None = Field(None, description='Отчество')
|
||||
last_name: str | None = Field(None, description='Фамилия')
|
||||
birth_date: date | None = Field(None, description='Дата рождения')
|
||||
encrypted_mnemonic: str | None = Field(None, description='Шифрованная мнемоника')
|
||||
phone: str | None = Field(None, description='Телефон')
|
||||
passport_data: str | None = Field(None, description='Паспортные данные')
|
||||
inn: str | None = Field(None, description='ИНН')
|
||||
erc20: str | None = Field(None, description='ERC-20 адрес')
|
||||
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
|
||||
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
|
||||
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
|
||||
created_at: datetime | None = Field(None, description='Время создания записи')
|
||||
updated_at: datetime | None = Field(None, description='Время последнего обновления')
|
||||
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at,
|
||||
)
|
||||
|
||||
|
||||
class SetAvatarPublicResponse(MeUserPublicResponse):
|
||||
webp_size_bytes: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description='Размер сохранённого файла аватара в формате WebP, байты',
|
||||
)
|
||||
@@ -1,75 +0,0 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from src.application.domain.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class ForgotPasswordStartRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ForgotPasswordCompleteRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class ChangePasswordConfirmRequest(BaseModel):
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
@@ -1,16 +0,0 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class SetPhoneRequest(BaseModel):
|
||||
phone: str
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_russian_phone(cls, v: str) -> str:
|
||||
cleaned = re.sub(r'[\s\-\(\)]', '', v)
|
||||
pattern = r'^(\+7|8)\d{10}$'
|
||||
if not re.match(pattern, cleaned):
|
||||
raise ValueError('Invalid Russian phone number')
|
||||
normalized = '+7' + cleaned[-10:]
|
||||
return normalized
|
||||
@@ -1 +0,0 @@
|
||||
from src.presentation.serializers.me_user import me_user_payload, me_user_public
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
|
||||
|
||||
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
|
||||
return MeUserPublicResponse.from_user(user)
|
||||
|
||||
|
||||
def me_user_payload(user: UserEntity) -> dict:
|
||||
return me_user_public(user).model_dump(mode='json')
|
||||
Reference in New Issue
Block a user