This commit is contained in:
2026-06-03 13:52:45 +03:00
commit f7309c4b4a
140 changed files with 7134 additions and 0 deletions

View 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)

View 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,
)

View 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)

View 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

View 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]

View 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)