init
This commit is contained in:
1
src/presentation/decorators/__init__.py
Normal file
1
src/presentation/decorators/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||
47
src/presentation/decorators/admin_auth.py
Normal file
47
src/presentation/decorators/admin_auth.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
|
||||
from src.application.contracts import IJwtService
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
|
||||
|
||||
def _extract_bearer_token(request: Request) -> str | None:
|
||||
auth = request.headers.get('Authorization')
|
||||
if not auth:
|
||||
return None
|
||||
scheme, param = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == 'bearer' and param:
|
||||
return param
|
||||
return None
|
||||
|
||||
|
||||
async def require_admin_access(
|
||||
request: Request,
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
) -> AdminAuthContext:
|
||||
token = _extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ApplicationException(status_code=401, message='Authorization Bearer token required')
|
||||
|
||||
payload = await jwt_service.decode_access_token(token)
|
||||
if payload.type != 'access':
|
||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
||||
|
||||
role = payload.role
|
||||
if not role:
|
||||
raise ApplicationException(status_code=401, message='Token missing role')
|
||||
|
||||
return AdminAuthContext(admin_user_id=payload.sub, role=role)
|
||||
|
||||
|
||||
def require_admin_role(*allowed_roles: str):
|
||||
allowed = set(allowed_roles)
|
||||
|
||||
async def dependency(auth: AdminAuthContext = Depends(require_admin_access)) -> AdminAuthContext:
|
||||
if auth.role not in allowed:
|
||||
raise ApplicationException(status_code=403, message='Insufficient permissions')
|
||||
return auth
|
||||
|
||||
return dependency
|
||||
61
src/presentation/decorators/csrf.py
Normal file
61
src/presentation/decorators/csrf.py
Normal 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
|
||||
171
src/presentation/decorators/rate_limit.py
Normal file
171
src/presentation/decorators/rate_limit.py
Normal 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
|
||||
6
src/presentation/dependencies/__init__.py
Normal file
6
src/presentation/dependencies/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_admin_login_command,
|
||||
get_admin_logout_command,
|
||||
get_admin_jwt_refresh_command,
|
||||
get_admin_me_command,
|
||||
)
|
||||
12
src/presentation/dependencies/cache.py
Normal file
12
src/presentation/dependencies/cache.py
Normal 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)
|
||||
145
src/presentation/dependencies/commands.py
Normal file
145
src/presentation/dependencies/commands.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import (
|
||||
AdminLoginCommand,
|
||||
GetAdminMeCommand,
|
||||
CreateOrganizationCommand,
|
||||
CreateOrganizationWalletsCommand,
|
||||
GetOrganizationCommand,
|
||||
GetPurchaseRequestCommand,
|
||||
ListOrganizationsCommand,
|
||||
GetOrganizationDocumentCommand,
|
||||
ListOrganizationDocumentsCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
SetPurchaseRequestQuoteCommand,
|
||||
UpdateOrganizationCommand,
|
||||
UpdatePurchaseRequestStatusCommand,
|
||||
UploadOrganizationDocumentCommand,
|
||||
)
|
||||
from src.application.contracts import IHashService, IJwtService, ILogger
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.dependencies.security import get_hash_service, get_jwt_service
|
||||
from src.presentation.dependencies.unit_of_work import get_unit_of_work
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _s3_documents_service() -> S3DocumentsService:
|
||||
return S3DocumentsService(
|
||||
bucket=settings.LEGAL_DOCS_S3_BUCKET,
|
||||
region=settings.LEGAL_DOCS_S3_REGION,
|
||||
access_key_id=settings.LEGAL_DOCS_S3_ACCESS_KEY_ID or None,
|
||||
secret_access_key=settings.LEGAL_DOCS_S3_SECRET_ACCESS_KEY or None,
|
||||
endpoint_url=settings.LEGAL_DOCS_S3_ENDPOINT_URL or None,
|
||||
key_prefix=settings.LEGAL_DOCS_S3_KEY_PREFIX,
|
||||
presigned_ttl_seconds=settings.LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def get_s3_documents_service() -> S3DocumentsService:
|
||||
return _s3_documents_service()
|
||||
|
||||
|
||||
def get_admin_login_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> AdminLoginCommand:
|
||||
return AdminLoginCommand(uow, hash_service, jwt_service, logger)
|
||||
|
||||
|
||||
def get_admin_me_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> GetAdminMeCommand:
|
||||
return GetAdminMeCommand(uow, logger)
|
||||
|
||||
|
||||
def get_create_organization_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> CreateOrganizationCommand:
|
||||
return CreateOrganizationCommand(uow, hash_service, logger)
|
||||
|
||||
|
||||
def get_create_organization_wallets_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> CreateOrganizationWalletsCommand:
|
||||
return CreateOrganizationWalletsCommand(uow, logger)
|
||||
|
||||
|
||||
def get_upload_organization_document_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> UploadOrganizationDocumentCommand:
|
||||
return UploadOrganizationDocumentCommand(uow, get_s3_documents_service(), logger)
|
||||
|
||||
|
||||
def get_list_organizations_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> ListOrganizationsCommand:
|
||||
return ListOrganizationsCommand(uow, logger)
|
||||
|
||||
|
||||
def get_get_organization_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> GetOrganizationCommand:
|
||||
return GetOrganizationCommand(uow, logger)
|
||||
|
||||
|
||||
def get_update_organization_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> UpdateOrganizationCommand:
|
||||
return UpdateOrganizationCommand(uow, logger)
|
||||
|
||||
|
||||
def get_list_organization_documents_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> ListOrganizationDocumentsCommand:
|
||||
return ListOrganizationDocumentsCommand(uow, logger)
|
||||
|
||||
|
||||
def get_get_organization_document_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> GetOrganizationDocumentCommand:
|
||||
return GetOrganizationDocumentCommand(uow, logger)
|
||||
|
||||
|
||||
def get_list_purchase_requests_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> ListPurchaseRequestsCommand:
|
||||
return ListPurchaseRequestsCommand(uow, logger)
|
||||
|
||||
|
||||
def get_get_purchase_request_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> GetPurchaseRequestCommand:
|
||||
return GetPurchaseRequestCommand(uow, logger)
|
||||
|
||||
|
||||
def get_update_purchase_request_status_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> UpdatePurchaseRequestStatusCommand:
|
||||
return UpdatePurchaseRequestStatusCommand(uow, logger)
|
||||
|
||||
|
||||
def get_set_purchase_request_quote_command(
|
||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> SetPurchaseRequestQuoteCommand:
|
||||
return SetPurchaseRequestQuoteCommand(uow, logger)
|
||||
7
src/presentation/dependencies/logger.py
Normal file
7
src/presentation/dependencies/logger.py
Normal 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
|
||||
8
src/presentation/dependencies/queue_messanger.py
Normal file
8
src/presentation/dependencies/queue_messanger.py
Normal 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()
|
||||
25
src/presentation/dependencies/security.py
Normal file
25
src/presentation/dependencies/security.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
from src.application.contracts import IHashService, IJwtService, ILogger
|
||||
from src.infrastructure.security import HashService, 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:
|
||||
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)
|
||||
10
src/presentation/dependencies/unit_of_work.py
Normal file
10
src/presentation/dependencies/unit_of_work.py
Normal 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)
|
||||
2
src/presentation/handler/__init__.py
Normal file
2
src/presentation/handler/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.presentation.handler.application_exception_handler import application_exception_handler
|
||||
from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler
|
||||
15
src/presentation/handler/application_exception_handler.py
Normal file
15
src/presentation/handler/application_exception_handler.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
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,
|
||||
)
|
||||
12
src/presentation/handler/unhandled_exception_handler.py
Normal file
12
src/presentation/handler/unhandled_exception_handler.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
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'},
|
||||
)
|
||||
2
src/presentation/middleware/__init__.py
Normal file
2
src/presentation/middleware/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.presentation.middleware.trace_id import TraceIDMiddleware
|
||||
from src.presentation.middleware.security_headers import SecurityHeadersMiddleware
|
||||
51
src/presentation/middleware/security_headers.py
Normal file
51
src/presentation/middleware/security_headers.py
Normal 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
|
||||
69
src/presentation/middleware/trace_id.py
Normal file
69
src/presentation/middleware/trace_id.py
Normal file
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
12
src/presentation/routing/__init__.py
Normal file
12
src/presentation/routing/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.presentation.routing.auth import auth_router
|
||||
from src.presentation.routing.documents import documents_router
|
||||
from src.presentation.routing.organizations import organizations_router
|
||||
from src.presentation.routing.purchase_requests import purchase_requests_router
|
||||
|
||||
v1_router = APIRouter(prefix='/v1')
|
||||
v1_router.include_router(auth_router)
|
||||
v1_router.include_router(organizations_router)
|
||||
v1_router.include_router(documents_router)
|
||||
v1_router.include_router(purchase_requests_router)
|
||||
47
src/presentation/routing/auth.py
Normal file
47
src/presentation/routing/auth.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
from src.application.commands import AdminLoginCommand, GetAdminMeCommand
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.presentation.decorators.admin_auth import require_admin_access
|
||||
from src.presentation.dependencies.commands import get_admin_login_command, get_admin_me_command
|
||||
from src.presentation.schemas.admin_auth import AdminLoginRequest, AdminLoginResponse, AdminMeResponse
|
||||
|
||||
auth_router = APIRouter(prefix='/auth', tags=['auth'])
|
||||
|
||||
|
||||
@auth_router.post('/login', response_model=AdminLoginResponse, status_code=status.HTTP_200_OK)
|
||||
async def admin_login(
|
||||
body: AdminLoginRequest,
|
||||
command: AdminLoginCommand = Depends(get_admin_login_command),
|
||||
):
|
||||
dto = await command(email=str(body.email), password=body.password)
|
||||
return AdminLoginResponse(
|
||||
access_token=dto.access_token,
|
||||
id=dto.id,
|
||||
email=dto.email,
|
||||
first_name=dto.first_name,
|
||||
last_name=dto.last_name,
|
||||
role=dto.role,
|
||||
)
|
||||
|
||||
|
||||
@auth_router.post('/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def admin_logout():
|
||||
"""Клиент удаляет access_token локально. Сервер stateless."""
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@auth_router.get('/me', response_model=AdminMeResponse)
|
||||
async def admin_me(
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: GetAdminMeCommand = Depends(get_admin_me_command),
|
||||
):
|
||||
admin = await command(auth.admin_user_id)
|
||||
return AdminMeResponse(
|
||||
id=admin.id,
|
||||
email=admin.email,
|
||||
first_name=admin.first_name,
|
||||
last_name=admin.last_name,
|
||||
role=admin.role,
|
||||
)
|
||||
70
src/presentation/routing/documents.py
Normal file
70
src/presentation/routing/documents.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
|
||||
|
||||
from src.application.commands import (
|
||||
GetOrganizationDocumentCommand,
|
||||
ListOrganizationDocumentsCommand,
|
||||
UploadOrganizationDocumentCommand,
|
||||
)
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_get_organization_document_command,
|
||||
get_list_organization_documents_command,
|
||||
get_s3_documents_service,
|
||||
get_upload_organization_document_command,
|
||||
)
|
||||
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
||||
from src.presentation.schemas.mappers import document_to_response
|
||||
from src.presentation.schemas.organization import DocumentResponse
|
||||
|
||||
documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents'])
|
||||
|
||||
|
||||
@documents_router.get('', response_model=list[DocumentResponse])
|
||||
async def list_documents(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: ListOrganizationDocumentsCommand = Depends(get_list_organization_documents_command),
|
||||
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||
):
|
||||
docs = await command(organization_id)
|
||||
result: list[DocumentResponse] = []
|
||||
for doc in docs:
|
||||
url = await s3.generate_presigned_download_url(key=doc.s3_key)
|
||||
result.append(document_to_response(doc, download_url=url))
|
||||
return result
|
||||
|
||||
|
||||
@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_document(
|
||||
organization_id: str,
|
||||
document_type: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command),
|
||||
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||
):
|
||||
body = await file.read()
|
||||
saved = await command(
|
||||
organization_id=organization_id,
|
||||
admin_user_id=auth.admin_user_id,
|
||||
document_type=document_type,
|
||||
file_name=file.filename or 'document',
|
||||
content_type=file.content_type or 'application/octet-stream',
|
||||
body=body,
|
||||
)
|
||||
url = await s3.generate_presigned_download_url(key=saved.s3_key)
|
||||
return document_to_response(saved, download_url=url)
|
||||
|
||||
|
||||
@documents_router.get('/{document_id}', response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
organization_id: str,
|
||||
document_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||
):
|
||||
doc = await command(organization_id, document_id)
|
||||
url = await s3.generate_presigned_download_url(key=doc.s3_key)
|
||||
return document_to_response(doc, download_url=url)
|
||||
70
src/presentation/routing/jwt.py
Normal file
70
src/presentation/routing/jwt.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
|
||||
from src.application.commands import AdminJwtRefreshCommand
|
||||
from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException
|
||||
from src.infrastructure.config import settings
|
||||
from src.presentation.dependencies.commands import get_admin_jwt_refresh_command
|
||||
|
||||
jwt_router = APIRouter(prefix='/jwt', tags=['jwt'])
|
||||
|
||||
|
||||
def _clear_auth_cookies(response: ORJSONResponse) -> None:
|
||||
response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN)
|
||||
response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN)
|
||||
|
||||
|
||||
def _set_auth_cookies(response: ORJSONResponse, access: str, refresh: str) -> None:
|
||||
response.set_cookie(
|
||||
key='access_token',
|
||||
value=access,
|
||||
httponly=True,
|
||||
secure=settings.ADMIN_COOKIE_SECURE,
|
||||
samesite='lax',
|
||||
path='/',
|
||||
domain=settings.ADMIN_COOKIE_DOMAIN,
|
||||
max_age=int(settings.JWT_ACCESS_TTL_SECONDS),
|
||||
)
|
||||
response.set_cookie(
|
||||
key='refresh_token',
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=settings.ADMIN_COOKIE_SECURE,
|
||||
samesite='lax',
|
||||
path='/',
|
||||
domain=settings.ADMIN_COOKIE_DOMAIN,
|
||||
max_age=int(settings.JWT_REFRESH_TTL_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
@jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
async def refresh_tokens(
|
||||
request: Request,
|
||||
command: AdminJwtRefreshCommand = Depends(get_admin_jwt_refresh_command),
|
||||
):
|
||||
refresh_token = request.cookies.get('refresh_token')
|
||||
if not refresh_token:
|
||||
response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401)
|
||||
_clear_auth_cookies(response)
|
||||
return response
|
||||
|
||||
xff = request.headers.get('x-forwarded-for')
|
||||
ip = xff.split(',')[0].strip() if xff else (request.client.host if request.client else None)
|
||||
user_agent = request.headers.get('user-agent')
|
||||
|
||||
try:
|
||||
tokens = await command(refresh_token=refresh_token, ip=ip, user_agent=user_agent)
|
||||
except RefreshConcurrentException:
|
||||
return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK)
|
||||
except ApplicationException as exc:
|
||||
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
response = ORJSONResponse({'result': False}, status_code=401)
|
||||
_clear_auth_cookies(response)
|
||||
return response
|
||||
raise
|
||||
|
||||
access, refresh = tokens
|
||||
response = ORJSONResponse({'result': True})
|
||||
_set_auth_cookies(response, access, refresh)
|
||||
return response
|
||||
102
src/presentation/routing/organizations.py
Normal file
102
src/presentation/routing/organizations.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from src.application.commands import (
|
||||
CreateOrganizationCommand,
|
||||
CreateOrganizationWalletsCommand,
|
||||
GetOrganizationCommand,
|
||||
ListOrganizationsCommand,
|
||||
UpdateOrganizationCommand,
|
||||
)
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_create_organization_command,
|
||||
get_create_organization_wallets_command,
|
||||
get_get_organization_command,
|
||||
get_list_organizations_command,
|
||||
get_update_organization_command,
|
||||
)
|
||||
from src.presentation.schemas.mappers import organization_to_response, wallet_to_response
|
||||
from src.presentation.schemas.organization import (
|
||||
CreateOrganizationRequest,
|
||||
OrganizationListResponse,
|
||||
OrganizationResponse,
|
||||
UpdateOrganizationRequest,
|
||||
WalletResponse,
|
||||
)
|
||||
|
||||
organizations_router = APIRouter(prefix='/organizations', tags=['organizations'])
|
||||
|
||||
|
||||
@organizations_router.get('', response_model=OrganizationListResponse)
|
||||
async def list_organizations(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
|
||||
):
|
||||
items, total = await command(limit=limit, offset=offset)
|
||||
return OrganizationListResponse(
|
||||
items=[organization_to_response(x) for x in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@organizations_router.post('', response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
body: CreateOrganizationRequest,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: CreateOrganizationCommand = Depends(get_create_organization_command),
|
||||
):
|
||||
org = await command(
|
||||
admin_user_id=auth.admin_user_id,
|
||||
email=str(body.email),
|
||||
password=body.password,
|
||||
name=body.name,
|
||||
short_name=body.short_name,
|
||||
inn=body.inn,
|
||||
ogrn=body.ogrn,
|
||||
kpp=body.kpp,
|
||||
legal_address=body.legal_address,
|
||||
actual_address=body.actual_address,
|
||||
bank_details=body.bank_details,
|
||||
contact_person=body.contact_person,
|
||||
contact_phone=body.contact_phone,
|
||||
status=body.status,
|
||||
)
|
||||
return organization_to_response(org)
|
||||
|
||||
|
||||
@organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
|
||||
async def get_organization(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: GetOrganizationCommand = Depends(get_get_organization_command),
|
||||
):
|
||||
org = await command(organization_id)
|
||||
return organization_to_response(org)
|
||||
|
||||
|
||||
@organizations_router.patch('/{organization_id}', response_model=OrganizationResponse)
|
||||
async def update_organization(
|
||||
organization_id: str,
|
||||
body: UpdateOrganizationRequest,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: UpdateOrganizationCommand = Depends(get_update_organization_command),
|
||||
):
|
||||
org = await command(organization_id, values=body.model_dump(exclude_unset=True))
|
||||
return organization_to_response(org)
|
||||
|
||||
|
||||
@organizations_router.post(
|
||||
'/{organization_id}/wallets/create',
|
||||
response_model=list[WalletResponse],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_organization_wallets(
|
||||
organization_id: str,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command),
|
||||
):
|
||||
wallets = await command(organization_id=organization_id)
|
||||
return [wallet_to_response(w) for w in wallets]
|
||||
90
src/presentation/routing/purchase_requests.py
Normal file
90
src/presentation/routing/purchase_requests.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from src.application.commands import (
|
||||
GetPurchaseRequestCommand,
|
||||
ListPurchaseRequestsCommand,
|
||||
SetPurchaseRequestQuoteCommand,
|
||||
UpdatePurchaseRequestStatusCommand,
|
||||
)
|
||||
from src.application.domain.dto import AdminAuthContext
|
||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_get_purchase_request_command,
|
||||
get_list_purchase_requests_command,
|
||||
get_set_purchase_request_quote_command,
|
||||
get_update_purchase_request_status_command,
|
||||
)
|
||||
from src.presentation.schemas.mappers import purchase_request_to_response
|
||||
from src.presentation.schemas.organization import (
|
||||
PurchaseRequestListResponse,
|
||||
PurchaseRequestResponse,
|
||||
SetPurchaseRequestQuoteBody,
|
||||
UpdatePurchaseRequestStatusBody,
|
||||
)
|
||||
|
||||
purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests'])
|
||||
|
||||
|
||||
@purchase_requests_router.get('', response_model=PurchaseRequestListResponse)
|
||||
async def list_purchase_requests(
|
||||
status_filter: str | None = Query(default=None, alias='status'),
|
||||
organization_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AdminAuthContext = Depends(require_admin_access),
|
||||
command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command),
|
||||
):
|
||||
items, total = await command(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
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: AdminAuthContext = Depends(require_admin_access),
|
||||
command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command),
|
||||
):
|
||||
item = await command(request_id)
|
||||
return purchase_request_to_response(item)
|
||||
|
||||
|
||||
@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse)
|
||||
async def update_purchase_request_status(
|
||||
request_id: str,
|
||||
body: UpdatePurchaseRequestStatusBody,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: UpdatePurchaseRequestStatusCommand = Depends(get_update_purchase_request_status_command),
|
||||
):
|
||||
item = await command(
|
||||
request_id,
|
||||
status=body.status,
|
||||
admin_comment=body.admin_comment,
|
||||
assigned_to=body.assigned_to,
|
||||
tx_hash=body.tx_hash,
|
||||
)
|
||||
return purchase_request_to_response(item)
|
||||
|
||||
|
||||
@purchase_requests_router.post('/{request_id}/quote', response_model=PurchaseRequestResponse)
|
||||
async def set_purchase_request_quote(
|
||||
request_id: str,
|
||||
body: SetPurchaseRequestQuoteBody,
|
||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||
command: SetPurchaseRequestQuoteCommand = Depends(get_set_purchase_request_quote_command),
|
||||
):
|
||||
item = await command(
|
||||
request_id,
|
||||
rub_amount=body.rub_amount,
|
||||
exchange_rate=body.exchange_rate,
|
||||
service_fee_percent=body.service_fee_percent,
|
||||
admin_comment=body.admin_comment,
|
||||
)
|
||||
return purchase_request_to_response(item)
|
||||
1
src/presentation/schemas/__init__.py
Normal file
1
src/presentation/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.schemas.error import ErrorResponse
|
||||
24
src/presentation/schemas/admin_auth.py
Normal file
24
src/presentation/schemas/admin_auth.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = 'Bearer'
|
||||
id: str
|
||||
email: str
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
role: str
|
||||
|
||||
|
||||
class AdminMeResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
role: str
|
||||
6
src/presentation/schemas/error.py
Normal file
6
src/presentation/schemas/error.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str = Field(title='Detail')
|
||||
87
src/presentation/schemas/mappers.py
Normal file
87
src/presentation/schemas/mappers.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities.organization import (
|
||||
LegalEntityEntity,
|
||||
OrganizationDocumentEntity,
|
||||
OrganizationWalletEntity,
|
||||
PurchaseRequestEntity,
|
||||
)
|
||||
from src.presentation.schemas.organization import (
|
||||
DocumentResponse,
|
||||
OrganizationResponse,
|
||||
PurchaseRequestResponse,
|
||||
WalletResponse,
|
||||
)
|
||||
|
||||
|
||||
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
|
||||
return OrganizationResponse(
|
||||
id=entity.id,
|
||||
user_id=entity.user_id,
|
||||
name=entity.name,
|
||||
short_name=entity.short_name,
|
||||
inn=entity.inn,
|
||||
ogrn=entity.ogrn,
|
||||
kpp=entity.kpp,
|
||||
legal_address=entity.legal_address,
|
||||
actual_address=entity.actual_address,
|
||||
bank_details=entity.bank_details,
|
||||
contact_person=entity.contact_person,
|
||||
contact_phone=entity.contact_phone,
|
||||
status=entity.status,
|
||||
kyc_verified=entity.kyc_verified,
|
||||
kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None,
|
||||
has_wallets=bool(entity.encrypted_mnemonic),
|
||||
created_by=entity.created_by,
|
||||
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
|
||||
return WalletResponse(
|
||||
id=entity.id,
|
||||
chain=entity.chain,
|
||||
address=entity.address,
|
||||
derivation_path=entity.derivation_path,
|
||||
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||
)
|
||||
|
||||
|
||||
def document_to_response(
|
||||
entity: OrganizationDocumentEntity,
|
||||
*,
|
||||
download_url: str | None = None,
|
||||
) -> DocumentResponse:
|
||||
return DocumentResponse(
|
||||
id=entity.id,
|
||||
organization_id=entity.organization_id,
|
||||
document_type=entity.document_type,
|
||||
file_name=entity.file_name,
|
||||
content_type=entity.content_type,
|
||||
file_size_bytes=entity.file_size_bytes,
|
||||
uploaded_by=entity.uploaded_by,
|
||||
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
assigned_to=entity.assigned_to,
|
||||
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,
|
||||
)
|
||||
120
src/presentation/schemas/organization.py
Normal file
120
src/presentation/schemas/organization.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class CreateOrganizationRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
name: str = Field(min_length=1, max_length=512)
|
||||
short_name: str | None = Field(default=None, max_length=256)
|
||||
inn: str = Field(min_length=10, max_length=12)
|
||||
ogrn: str | None = Field(default=None, max_length=15)
|
||||
kpp: str | None = Field(default=None, max_length=9)
|
||||
legal_address: str | None = None
|
||||
actual_address: str | None = None
|
||||
bank_details: dict[str, Any] | None = None
|
||||
contact_person: str | None = Field(default=None, max_length=256)
|
||||
contact_phone: str | None = Field(default=None, max_length=16)
|
||||
status: str = 'active'
|
||||
|
||||
|
||||
class UpdateOrganizationRequest(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=512)
|
||||
short_name: str | None = Field(default=None, max_length=256)
|
||||
ogrn: str | None = Field(default=None, max_length=15)
|
||||
kpp: str | None = Field(default=None, max_length=9)
|
||||
legal_address: str | None = None
|
||||
actual_address: str | None = None
|
||||
bank_details: dict[str, Any] | None = None
|
||||
contact_person: str | None = Field(default=None, max_length=256)
|
||||
contact_phone: str | None = Field(default=None, max_length=16)
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
short_name: str | None
|
||||
inn: str
|
||||
ogrn: str | None
|
||||
kpp: str | None
|
||||
legal_address: str | None
|
||||
actual_address: str | None
|
||||
bank_details: dict[str, Any] | None
|
||||
contact_person: str | None
|
||||
contact_phone: str | None
|
||||
status: str
|
||||
kyc_verified: bool
|
||||
kyc_verified_at: str | None
|
||||
has_wallets: bool
|
||||
created_by: str | None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
|
||||
class OrganizationListResponse(BaseModel):
|
||||
items: list[OrganizationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WalletResponse(BaseModel):
|
||||
id: str
|
||||
chain: str
|
||||
address: str
|
||||
derivation_path: str
|
||||
created_at: str | None
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
document_type: str
|
||||
file_name: str
|
||||
content_type: str
|
||||
file_size_bytes: int
|
||||
uploaded_by: str | None
|
||||
created_at: str | None
|
||||
download_url: str | None = None
|
||||
|
||||
|
||||
class UpdatePurchaseRequestStatusBody(BaseModel):
|
||||
status: str
|
||||
admin_comment: str | None = None
|
||||
assigned_to: str | None = None
|
||||
tx_hash: str | None = None
|
||||
|
||||
|
||||
class SetPurchaseRequestQuoteBody(BaseModel):
|
||||
rub_amount: Decimal = Field(gt=0)
|
||||
exchange_rate: Decimal = Field(gt=0)
|
||||
service_fee_percent: Decimal | None = None
|
||||
admin_comment: str | None = None
|
||||
|
||||
|
||||
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
|
||||
assigned_to: str | None
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
class PurchaseRequestListResponse(BaseModel):
|
||||
items: list[PurchaseRequestResponse]
|
||||
total: int
|
||||
Reference in New Issue
Block a user