This commit is contained in:
2026-06-03 13:49:16 +03:00
commit 284a5fa468
138 changed files with 6660 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from src.presentation.decorators.csrf import csrf_protect
from src.presentation.decorators.rate_limit import rate_limit, _email_rl_key as email_rl_key
from src.presentation.decorators.auth import require_access_token
from src.presentation.decorators.cache import cached

View File

@@ -0,0 +1,36 @@
from fastapi import Depends, Request
from fastapi.security.utils import get_authorization_scheme_param
from src.application.contracts import IJwtService
from src.application.domain.exceptions import UnauthorizedException
from src.application.domain.dto import AccessTokenPayload, AuthContext
from src.presentation.dependencies import get_jwt_service
def _extract_access_token(request: Request) -> str | None:
token = request.cookies.get('access_token')
if token:
return token
auth = request.headers.get('Authorization')
if auth:
scheme, param = get_authorization_scheme_param(auth)
if scheme.lower() == 'bearer' and param:
return param
return None
async def require_access_token(
request: Request,
jwt_service: IJwtService = Depends(get_jwt_service),
) -> AuthContext:
token = _extract_access_token(request)
if not token:
raise UnauthorizedException(message='Not authenticated')
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
if payload.type != 'access':
raise UnauthorizedException(message='Invalid token type')
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)

View File

@@ -0,0 +1,46 @@
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

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import inspect
from functools import wraps
from typing import Callable, Awaitable, Any, Optional, Annotated
from fastapi import Request, Header
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.security import CsrfService
def csrf_protect(
expected_subject_getter: Optional[Callable[[Request], Optional[str]]] = None,
):
def decorator(func: Callable[..., Awaitable[Any]]):
sig = inspect.signature(func)
params = list(sig.parameters.values())
has_request = any(p.annotation is Request or p.name == 'request' for p in params)
if not has_request:
raise RuntimeError('csrf_protect requires endpoint to accept `request: Request`')
has_header = any(p.name == 'x_csrf_token' for p in params)
if not has_header:
params.append(
inspect.Parameter(
name='x_csrf_token',
kind=inspect.Parameter.KEYWORD_ONLY,
default=None,
annotation=Annotated[str | None, Header(alias='X-CSRF-Token')],
)
)
@wraps(func)
async def wrapper(*args, **kwargs):
request: Request | None = kwargs.get('request')
if request is None:
for arg in args:
if isinstance(arg, Request):
request = arg
break
if request is None:
raise ApplicationException(
status_code=500,
message='Request is required for CSRF protection',
)
csrf = CsrfService()
cookie_token, _ = csrf.extract(request.cookies, request.headers)
header_token = kwargs.get('x_csrf_token')
expected_subject = expected_subject_getter(request) if expected_subject_getter else None
csrf.verify_pair(cookie_token, header_token, expected_subject)
kwargs.pop('x_csrf_token', None)
return await func(*args, **kwargs)
wrapper.__signature__ = sig.replace(parameters=params)
return wrapper
return decorator

View File

@@ -0,0 +1,171 @@
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

View File

@@ -0,0 +1,7 @@
from src.presentation.dependencies.commands import (
get_create_purchase_request_command,
get_get_purchase_request_command,
get_list_purchase_requests_command,
)
from src.presentation.dependencies.security import get_jwt_service
from src.presentation.dependencies.cache import get_redis, get_cache

View File

@@ -0,0 +1,12 @@
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)

View File

@@ -0,0 +1,31 @@
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import (
CreatePurchaseRequestCommand,
GetPurchaseRequestCommand,
ListPurchaseRequestsCommand,
)
from src.application.contracts import ILogger
from src.presentation.dependencies.logger import get_logger
from src.presentation.dependencies.unit_of_work import get_unit_of_work
def get_create_purchase_request_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> CreatePurchaseRequestCommand:
return CreatePurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)
def get_list_purchase_requests_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> ListPurchaseRequestsCommand:
return ListPurchaseRequestsCommand(unit_of_work=unit_of_work, logger=logger)
def get_get_purchase_request_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetPurchaseRequestCommand:
return GetPurchaseRequestCommand(unit_of_work=unit_of_work, logger=logger)

View File

@@ -0,0 +1,7 @@
from functools import lru_cache
from src.application.contracts import ILogger
from src.infrastructure.logger import logger
@lru_cache
def get_logger() -> ILogger:
return logger

View File

@@ -0,0 +1,8 @@
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()

View File

@@ -0,0 +1,32 @@
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

View File

@@ -0,0 +1,25 @@
from functools import lru_cache
from fastapi import Depends
from src.application.contracts import IJwtService, ILogger, IHashService
from src.infrastructure.security import JwtService, HashService
from src.infrastructure.vault import JwtKeyStore
from src.presentation.dependencies.logger import get_logger
@lru_cache(maxsize=1)
def _hash_service(logger: ILogger) -> IHashService:
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()
return JwtService(logger=logger, key_store=key_store)
def get_jwt_service(logger: ILogger = Depends(get_logger)) -> IJwtService:
return _jwt_service(logger)

View File

@@ -0,0 +1,10 @@
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.infrastructure.database import UnitOfWork
from src.infrastructure.database.context import async_session_maker
from src.infrastructure.logger import get_logger
def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork:
return UnitOfWork(session_factory=async_session_maker, logger=logger)

View File

@@ -0,0 +1,4 @@
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
from src.presentation.handlers.application_handler import application_exception_handler
from src.presentation.handlers.http_exception_handler import http_exception_handler
from src.presentation.handlers.validation_handler import validation_exception_handler

View File

@@ -0,0 +1,17 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request
from src.application.domain.exceptions import ApplicationException
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
detail = exc.message
if 500 <= exc.status_code:
detail = "Internal Server Error"
return ORJSONResponse(
status_code=exc.status_code,
content={"detail": detail},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -0,0 +1,11 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from starlette.exceptions import HTTPException
async def http_exception_handler(_request: Request, exc: HTTPException) -> ORJSONResponse:
return ORJSONResponse(
status_code=exc.status_code,
content={'detail': exc.detail},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -0,0 +1,12 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request
from starlette import status
from src.infrastructure.logger import logger
async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJSONResponse:
logger.exception(f'Unhandled exception: {type(exc).__name__}')
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'detail': 'Internal Server Error'},
)

View File

@@ -0,0 +1,10 @@
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse
async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> ORJSONResponse:
return ORJSONResponse(
status_code=422,
content={'detail': exc.errors()},
)

View File

@@ -0,0 +1,2 @@
from src.presentation.middleware.trace_id import TraceIDMiddleware
from src.presentation.middleware.security_headers import SecurityHeadersMiddleware

View File

@@ -0,0 +1,51 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
*,
hsts: bool = True,
hsts_max_age: int = 31536000, # 1 год
hsts_include_subdomains: bool = True,
hsts_preload: bool = False,
frame_options: str = 'DENY', # или 'SAMEORIGIN'
referrer_policy: str = 'strict-origin-when-cross-origin',
content_security_policy: str | None = None,
):
super().__init__(app)
self.hsts = hsts
self.hsts_max_age = hsts_max_age
self.hsts_include_subdomains = hsts_include_subdomains
self.hsts_preload = hsts_preload
self.frame_options = frame_options
self.referrer_policy = referrer_policy
self.csp = content_security_policy
async def dispatch(self, request: Request, call_next) -> Response:
response: Response = await call_next(request)
if request.url.path in ('/docs', '/redoc', '/openapi.json'):
return response
if self.hsts and request.url.scheme == 'https':
hsts = f'max-age={self.hsts_max_age}'
if self.hsts_include_subdomains:
hsts += '; includeSubDomains'
if self.hsts_preload:
hsts += '; preload'
response.headers['Strict-Transport-Security'] = hsts
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = self.frame_options
response.headers['Referrer-Policy'] = self.referrer_policy
if self.csp:
response.headers['Content-Security-Policy'] = self.csp
return response

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from typing import Optional
from contextvars import Token
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ulid import ULID
from src.application.contracts import ILogger
from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var
class TraceIDMiddleware:
def __init__(
self,
app: ASGIApp,
logger: ILogger,
response_header_name: str = "X-Trace-ID",
attach_response_header: bool = True,
) -> None:
self.app = app
self.logger = logger
self.response_header_name = response_header_name
self.attach_response_header = attach_response_header
def _is_excluded(self, path: str) -> bool:
return any(path == p or path.startswith(p.rstrip("/") + "/") for p in settings.EXCLUDED_PATHS)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope)
if self._is_excluded(request.url.path):
await self.app(scope, receive, send)
return
trace_id = request.headers.get("X-Trace-ID") or request.headers.get("X-Request-ID")
if not trace_id:
trace_id = str(ULID())
request.state.trace_id = trace_id
token: Token = trace_id_var.set(trace_id)
self.logger.debug(f"Request started: {request.method} {request.url} - TraceID: {trace_id}")
status_code_holder: dict[str, Optional[int]] = {"status": None}
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
status_code_holder["status"] = int(message["status"])
if self.attach_response_header:
headers = list(message.get("headers", []))
headers.append((self.response_header_name.lower().encode(), trace_id.encode()))
message["headers"] = headers
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
status = status_code_holder["status"]
status_part = f"{status}" if status is not None else "unknown"
self.logger.debug(
f"Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}"
)
trace_id_var.reset(token)
# from __future__ import annotations
# from typing import Optional
# from starlette.requests import Request
# from starlette.types import ASGIApp, Message, Receive, Scope, Send
# from ulid import ULID
# from src.application.contracts import ILogger
# from src.infrastructure.config.settings import settings
#
#
# class TraceIDMiddleware:
# def __init__(
# self,
# app: ASGIApp,
# logger: ILogger,
# response_header_name: str = 'X-Trace-ID',
# attach_response_header: bool = True,
# ) -> None:
# self.app = app
# self.logger = logger
# self.response_header_name = response_header_name
# self.attach_response_header = attach_response_header
#
# def _is_excluded(self, path: str) -> bool:
# return any(path == p or path.startswith(p.rstrip('/') + '/') for p in settings.EXCLUDED_PATHS)
#
# async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# if scope['type'] != 'http':
# await self.app(scope, receive, send)
# return
#
# request = Request(scope)
#
# if self._is_excluded(request.url.path):
# await self.app(scope, receive, send)
# return
#
# trace_id = request.headers.get('X-Trace-ID') or request.headers.get('X-Request-ID')
# if not trace_id:
# trace_id = str(ULID())
#
# request.state.trace_id = trace_id
# self.logger.set_trace_id(trace_id)
#
# self.logger.debug(f'Request started: {request.method} {request.url} - TraceID: {trace_id}')
#
# status_code_holder: dict[str, Optional[int]] = {'status': None}
#
# async def send_wrapper(message: Message) -> None:
# if message['type'] == 'http.response.start':
# status_code_holder['status'] = int(message['status'])
#
# if self.attach_response_header:
# headers = list(message.get('headers', []))
# headers.append((self.response_header_name.lower().encode(), trace_id.encode()))
# message['headers'] = headers
# await send(message)
#
# try:
# await self.app(scope, receive, send_wrapper)
# finally:
# status = status_code_holder['status']
# status_part = f'{status}' if status is not None else 'unknown'
# self.logger.debug(f'Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}')
# self.logger.clear_trace_id()

View File

@@ -0,0 +1,3 @@
from src.presentation.routing.purchase_requests import purchase_requests_router
__all__ = ['purchase_requests_router']

View File

@@ -0,0 +1,55 @@
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)

View File

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

View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
deals_router = APIRouter(prefix='/deals')
@deals_router.get(path='')
async def deals():
pass

View File

@@ -0,0 +1,11 @@
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

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, Query, status
from src.application.commands import (
CreatePurchaseRequestCommand,
GetPurchaseRequestCommand,
ListPurchaseRequestsCommand,
)
from src.application.domain.dto import AuthContext
from src.presentation.decorators.auth import require_access_token
from src.presentation.dependencies.commands import (
get_create_purchase_request_command,
get_get_purchase_request_command,
get_list_purchase_requests_command,
)
from src.presentation.schemas.purchase_request import (
CreatePurchaseRequestBody,
PurchaseRequestListResponse,
PurchaseRequestResponse,
)
from src.presentation.serializers.purchase_request import purchase_request_to_response
purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests'])
@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED)
async def create_purchase_request(
body: CreatePurchaseRequestBody,
auth: AuthContext = Depends(require_access_token),
command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command),
):
item = await command(
auth.user_id,
usdt_amount=body.usdt_amount,
comment=body.comment,
target_wallet_chain=body.target_wallet_chain,
target_wallet_address=body.target_wallet_address,
)
return purchase_request_to_response(item)
@purchase_requests_router.get('', response_model=PurchaseRequestListResponse)
async def list_purchase_requests(
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),
auth: AuthContext = Depends(require_access_token),
command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command),
):
items, total = await command(
auth.user_id,
status=status_filter,
limit=limit,
offset=offset,
)
return PurchaseRequestListResponse(
items=[purchase_request_to_response(x) for x in items],
total=total,
)
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
async def get_purchase_request(
request_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command),
):
item = await command(auth.user_id, request_id)
return purchase_request_to_response(item)

View File

@@ -0,0 +1,10 @@
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

View File

@@ -0,0 +1,19 @@
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='Список ошибок валидации тела или параметров запроса'
)

View File

@@ -0,0 +1,69 @@
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}

View File

@@ -0,0 +1,116 @@
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

View File

@@ -0,0 +1,35 @@
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

View File

@@ -0,0 +1,23 @@
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

View File

@@ -0,0 +1,59 @@
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, байты',
)

View File

@@ -0,0 +1,75 @@
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)

View File

@@ -0,0 +1,16 @@
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

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from decimal import Decimal
from pydantic import BaseModel, Field
class CreatePurchaseRequestBody(BaseModel):
usdt_amount: Decimal = Field(gt=0)
comment: str | None = None
target_wallet_chain: str | None = Field(default='ETH', max_length=16)
target_wallet_address: str | None = Field(default=None, max_length=128)
class PurchaseRequestResponse(BaseModel):
id: str
organization_id: str
status: str
usdt_amount: str
rub_amount: str | None
exchange_rate: str | None
service_fee_percent: str | None
comment: str | None
admin_comment: str | None
target_wallet_chain: str | None
target_wallet_address: str | None
tx_hash: str | None
created_at: str | None
updated_at: str | None
completed_at: str | None
class PurchaseRequestListResponse(BaseModel):
items: list[PurchaseRequestResponse]
total: int

View File

@@ -0,0 +1 @@
from src.presentation.serializers.me_user import me_user_payload, me_user_public

View File

@@ -0,0 +1,13 @@
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')

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from src.application.domain.entities.purchase_request import PurchaseRequestEntity
from src.presentation.schemas.purchase_request import PurchaseRequestResponse
def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseRequestResponse:
return PurchaseRequestResponse(
id=entity.id,
organization_id=entity.organization_id,
status=entity.status,
usdt_amount=str(entity.usdt_amount),
rub_amount=str(entity.rub_amount) if entity.rub_amount is not None else None,
exchange_rate=str(entity.exchange_rate) if entity.exchange_rate is not None else None,
service_fee_percent=str(entity.service_fee_percent) if entity.service_fee_percent is not None else None,
comment=entity.comment,
admin_comment=entity.admin_comment,
target_wallet_chain=entity.target_wallet_chain,
target_wallet_address=entity.target_wallet_address,
tx_hash=entity.tx_hash,
created_at=entity.created_at.isoformat() if entity.created_at else None,
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
)