Add risk and audit admin APIs
This commit is contained in:
@@ -11,6 +11,7 @@ from src.application.abstractions.repositories import (
|
|||||||
IUserRepository,
|
IUserRepository,
|
||||||
IWalletRepository,
|
IWalletRepository,
|
||||||
IOrderRepository,
|
IOrderRepository,
|
||||||
|
IRiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,3 +46,6 @@ class IUnitOfWork(Protocol):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def order_repository(self) -> IOrderRepository: ...
|
def order_repository(self) -> IOrderRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository: ...
|
||||||
@@ -6,3 +6,4 @@ from src.application.abstractions.repositories.i_organization_wallet_repository
|
|||||||
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
||||||
from src.application.abstractions.repositories.i_wallets_repository import IWalletRepository
|
from src.application.abstractions.repositories.i_wallets_repository import IWalletRepository
|
||||||
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
|
||||||
|
|
||||||
|
class IRiskRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -44,6 +44,7 @@ from src.application.commands.orders_commands import (
|
|||||||
ListOrdersByDateCommand,
|
ListOrdersByDateCommand,
|
||||||
GetOrderCommand,
|
GetOrderCommand,
|
||||||
)
|
)
|
||||||
|
from src.application.commands.risk_commands import GetFunnelCommand,GetRiskSummaryCommand,ListAuditEventsCommand,ListRiskAssessmentsCommand
|
||||||
from src.application.commands.analytics_commands import (
|
from src.application.commands.analytics_commands import (
|
||||||
GetPurchaseAnalyticsForYearCommand,
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
GetOrdersAnalyticsForYearCommand,
|
GetOrdersAnalyticsForYearCommand,
|
||||||
@@ -92,4 +93,8 @@ __all__ = [
|
|||||||
'GetOrdersAnalyticsForMonthCommand',
|
'GetOrdersAnalyticsForMonthCommand',
|
||||||
'GetPurchaseAnalyticsForWeekCommand',
|
'GetPurchaseAnalyticsForWeekCommand',
|
||||||
'GetOrdersAnalyticsForWeekCommand',
|
'GetOrdersAnalyticsForWeekCommand',
|
||||||
|
'ListRiskAssessmentsCommand',
|
||||||
|
'GetRiskSummaryCommand',
|
||||||
|
'ListAuditEventsCommand',
|
||||||
|
'GetFunnelCommand',
|
||||||
]
|
]
|
||||||
|
|||||||
41
src/application/commands/risk_commands.py
Normal file
41
src/application/commands/risk_commands.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class ListRiskAssessmentsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_assessments(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GetRiskSummaryCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> RiskSummaryEntity:
|
||||||
|
return await self._uow.risk_repository.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ListAuditEventsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_audit_events(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
|
||||||
|
|
||||||
|
class GetFunnelCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> FunnelEntity:
|
||||||
|
return await self._uow.risk_repository.funnel()
|
||||||
48
src/application/domain/entities/risk.py
Normal file
48
src/application/domain/entities/risk.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskAssessmentEntity:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuditEventEntity:
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskSummaryEntity:
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FunnelEntity:
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
@@ -7,6 +7,7 @@ from src.infrastructure.database.models.organization_wallet import OrganizationW
|
|||||||
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
|
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
|
||||||
from src.infrastructure.database.models.wallets import WalletModel
|
from src.infrastructure.database.models.wallets import WalletModel
|
||||||
from src.infrastructure.database.models.order import OrderModel
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Base',
|
'Base',
|
||||||
|
|||||||
32
src/infrastructure/database/models/risk.py
Normal file
32
src/infrastructure/database/models/risk.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||||
|
__tablename__ = "risk_assessments"
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(String(26), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
order_id: Mapped[str | None] = mapped_column(String(26), ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False, default="individual", server_default="individual", index=True)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
decision: Mapped[str] = mapped_column(String(32), nullable=False, default="allow", index=True)
|
||||||
|
reasons: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"))
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventModel(Base, UlidPrimaryKeyMixin):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32), nullable=False, default="system", server_default="system", index=True)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", server_default="info", index=True)
|
||||||
|
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||||
|
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
@@ -6,6 +6,7 @@ from src.infrastructure.database.repositories.organization_wallet_repository imp
|
|||||||
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
|
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
|
||||||
from src.infrastructure.database.repositories.wallets_repository import WalletRepository
|
from src.infrastructure.database.repositories.wallets_repository import WalletRepository
|
||||||
from src.infrastructure.database.repositories.orders_repository import OrderRepository
|
from src.infrastructure.database.repositories.orders_repository import OrderRepository
|
||||||
|
from src.infrastructure.database.repositories.risk_repository import RiskRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AdminUserRepository',
|
'AdminUserRepository',
|
||||||
|
|||||||
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, func, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
from src.infrastructure.database.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRepository(IRiskRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assessment(model: RiskAssessmentModel) -> RiskAssessmentEntity:
|
||||||
|
return RiskAssessmentEntity(
|
||||||
|
id=model.id,
|
||||||
|
user_id=model.user_id,
|
||||||
|
order_id=model.order_id,
|
||||||
|
subject_type=model.subject_type,
|
||||||
|
score=model.score,
|
||||||
|
decision=model.decision,
|
||||||
|
reasons=list(model.reasons or []),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event(model: AuditEventModel) -> AuditEventEntity:
|
||||||
|
return AuditEventEntity(
|
||||||
|
id=model.id,
|
||||||
|
actor_type=model.actor_type,
|
||||||
|
actor_id=model.actor_id,
|
||||||
|
action=model.action,
|
||||||
|
entity_type=model.entity_type,
|
||||||
|
entity_id=model.entity_id,
|
||||||
|
severity=model.severity,
|
||||||
|
metadata=dict(model.metadata_json or {}),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
stmt = select(RiskAssessmentModel).order_by(RiskAssessmentModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(RiskAssessmentModel)
|
||||||
|
filters = []
|
||||||
|
if decision:
|
||||||
|
filters.append(RiskAssessmentModel.decision == decision)
|
||||||
|
if user_id:
|
||||||
|
filters.append(RiskAssessmentModel.user_id == user_id)
|
||||||
|
if order_id:
|
||||||
|
filters.append(RiskAssessmentModel.order_id == order_id)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._assessment(row) for row in rows], total
|
||||||
|
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
stmt = select(
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "allow").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "manual_review").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "reject").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.avg(RiskAssessmentModel.score), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.score >= 60).cast(Integer)), 0),
|
||||||
|
)
|
||||||
|
row = (await self._session.execute(stmt)).one()
|
||||||
|
return RiskSummaryEntity(
|
||||||
|
total=int(row[0] or 0),
|
||||||
|
allow=int(row[1] or 0),
|
||||||
|
manual_review=int(row[2] or 0),
|
||||||
|
reject=int(row[3] or 0),
|
||||||
|
average_score=float(row[4] or 0),
|
||||||
|
high_risk=int(row[5] or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
stmt = select(AuditEventModel).order_by(AuditEventModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(AuditEventModel)
|
||||||
|
filters = []
|
||||||
|
if severity:
|
||||||
|
filters.append(AuditEventModel.severity == severity)
|
||||||
|
if action:
|
||||||
|
filters.append(AuditEventModel.action == action)
|
||||||
|
if entity_type:
|
||||||
|
filters.append(AuditEventModel.entity_type == entity_type)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._event(row) for row in rows], total
|
||||||
|
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
registrations = int((await self._session.execute(select(func.count()).select_from(UserModel))).scalar_one())
|
||||||
|
kyc_started = int((await self._session.execute(text("select count(distinct user_id) from kyc"))).scalar_one())
|
||||||
|
kyc_completed = int((await self._session.execute(select(func.count()).select_from(UserModel).where(UserModel.kyc_verified.is_(True)))).scalar_one())
|
||||||
|
first_payment = int((await self._session.execute(select(func.count(func.distinct(OrderModel.user_id))))).scalar_one())
|
||||||
|
successful_operations = int((await self._session.execute(text("select count(*) from payments where status = 'completed'"))).scalar_one())
|
||||||
|
return FunnelEntity(
|
||||||
|
registrations=registrations,
|
||||||
|
kyc_started=kyc_started,
|
||||||
|
kyc_completed=kyc_completed,
|
||||||
|
first_payment=first_payment,
|
||||||
|
successful_operations=successful_operations,
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ from src.application.abstractions.repositories import (
|
|||||||
IUserRepository,
|
IUserRepository,
|
||||||
IWalletRepository,
|
IWalletRepository,
|
||||||
IOrderRepository,
|
IOrderRepository,
|
||||||
|
IRiskRepository,
|
||||||
)
|
)
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.exceptions import RefreshConcurrentException
|
from src.application.domain.exceptions import RefreshConcurrentException
|
||||||
@@ -22,6 +23,7 @@ from src.infrastructure.database.repositories import (
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
WalletRepository,
|
WalletRepository,
|
||||||
OrderRepository,
|
OrderRepository,
|
||||||
|
RiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,10 +39,12 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._purchase_request_repository: IPurchaseRequestRepository | None = None
|
self._purchase_request_repository: IPurchaseRequestRepository | None = None
|
||||||
self._wallet_repository: IWalletRepository | None = None
|
self._wallet_repository: IWalletRepository | None = None
|
||||||
self._order_repository: IOrderRepository | None = None
|
self._order_repository: IOrderRepository | None = None
|
||||||
|
self._risk_repository: IRiskRepository | None = None
|
||||||
self._logger: ILogger = logger
|
self._logger: ILogger = logger
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
self._session = self.session_factory()
|
self._session = self.session_factory()
|
||||||
|
self._risk_repository = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
@@ -114,3 +118,9 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
session=self._session, logger=self._logger
|
session=self._session, logger=self._logger
|
||||||
)
|
)
|
||||||
return self._order_repository
|
return self._order_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository:
|
||||||
|
if self._risk_repository is None:
|
||||||
|
self._risk_repository = RiskRepository(session=self._session, logger=self._logger)
|
||||||
|
return self._risk_repository
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ from src.application.commands import (
|
|||||||
GetOrdersAnalyticsForMonthCommand,
|
GetOrdersAnalyticsForMonthCommand,
|
||||||
GetPurchaseAnalyticsForWeekCommand,
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
GetOrdersAnalyticsForWeekCommand,
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
|
ListRiskAssessmentsCommand,
|
||||||
|
GetRiskSummaryCommand,
|
||||||
|
ListAuditEventsCommand,
|
||||||
|
GetFunnelCommand,
|
||||||
)
|
)
|
||||||
from src.application.contracts import IHashService, IJwtService, ILogger
|
from src.application.contracts import IHashService, IJwtService, ILogger
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
@@ -320,3 +324,27 @@ def get_get_orders_analytics_for_week_command(
|
|||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
) -> GetOrdersAnalyticsForWeekCommand:
|
) -> GetOrdersAnalyticsForWeekCommand:
|
||||||
return GetOrdersAnalyticsForWeekCommand(uow, logger)
|
return GetOrdersAnalyticsForWeekCommand(uow, logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_risk_assessments_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListRiskAssessmentsCommand:
|
||||||
|
return ListRiskAssessmentsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_risk_summary_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetRiskSummaryCommand:
|
||||||
|
return GetRiskSummaryCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_audit_events_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListAuditEventsCommand:
|
||||||
|
return ListAuditEventsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_funnel_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetFunnelCommand:
|
||||||
|
return GetFunnelCommand(uow)
|
||||||
@@ -9,6 +9,7 @@ from src.presentation.routing.users import users_router
|
|||||||
from src.presentation.routing.search import search_router
|
from src.presentation.routing.search import search_router
|
||||||
from src.presentation.routing.orders import orders_router
|
from src.presentation.routing.orders import orders_router
|
||||||
from src.presentation.routing.analytics import analytics_router
|
from src.presentation.routing.analytics import analytics_router
|
||||||
|
from src.presentation.routing.risk import audit_router, risk_router
|
||||||
|
|
||||||
v1_router = APIRouter(prefix='/v1')
|
v1_router = APIRouter(prefix='/v1')
|
||||||
v1_router.include_router(auth_router)
|
v1_router.include_router(auth_router)
|
||||||
@@ -20,3 +21,5 @@ v1_router.include_router(users_router)
|
|||||||
v1_router.include_router(search_router)
|
v1_router.include_router(search_router)
|
||||||
v1_router.include_router(orders_router)
|
v1_router.include_router(orders_router)
|
||||||
v1_router.include_router(analytics_router)
|
v1_router.include_router(analytics_router)
|
||||||
|
v1_router.include_router(risk_router)
|
||||||
|
v1_router.include_router(audit_router)
|
||||||
@@ -9,11 +9,13 @@ from src.presentation.dependencies.commands import (
|
|||||||
get_get_orders_analytics_for_month_command,
|
get_get_orders_analytics_for_month_command,
|
||||||
get_get_purchase_analytics_for_week_command,
|
get_get_purchase_analytics_for_week_command,
|
||||||
get_get_orders_analytics_for_week_command,
|
get_get_orders_analytics_for_week_command,
|
||||||
|
get_get_funnel_command,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.analytics import (
|
from src.presentation.schemas.analytics import (
|
||||||
OrderAnalyticsResponse,
|
OrderAnalyticsResponse,
|
||||||
PurchaseRequestAnalyticsResponse,
|
PurchaseRequestAnalyticsResponse,
|
||||||
)
|
)
|
||||||
|
from src.presentation.schemas.risk import FunnelResponse
|
||||||
from src.application.commands.analytics_commands import (
|
from src.application.commands.analytics_commands import (
|
||||||
GetPurchaseAnalyticsForYearCommand,
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
GetOrdersAnalyticsForYearCommand,
|
GetOrdersAnalyticsForYearCommand,
|
||||||
@@ -22,6 +24,7 @@ from src.application.commands.analytics_commands import (
|
|||||||
GetPurchaseAnalyticsForWeekCommand,
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
GetOrdersAnalyticsForWeekCommand,
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
)
|
)
|
||||||
|
from src.application.commands.risk_commands import GetFunnelCommand
|
||||||
|
|
||||||
analytics_router = APIRouter(prefix='/analytics', tags=['analytics'])
|
analytics_router = APIRouter(prefix='/analytics', tags=['analytics'])
|
||||||
|
|
||||||
@@ -158,3 +161,12 @@ async def get_orders_analytics_for_week(
|
|||||||
total_summed_service_fees=total_sum,
|
total_summed_service_fees=total_sum,
|
||||||
period=period,
|
period=period,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_router.get('/funnel', response_model=FunnelResponse)
|
||||||
|
async def get_funnel(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetFunnelCommand = Depends(get_get_funnel_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return FunnelResponse(**item.__dict__)
|
||||||
|
|||||||
80
src/presentation/routing/risk.py
Normal file
80
src/presentation/routing/risk.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.commands.risk_commands import GetRiskSummaryCommand, ListAuditEventsCommand, ListRiskAssessmentsCommand
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import get_get_risk_summary_command, get_list_audit_events_command, get_list_risk_assessments_command
|
||||||
|
from src.presentation.schemas.risk import AuditEventResponse, AuditEventsResponse, RiskAssessmentResponse, RiskAssessmentsResponse, RiskSummaryResponse
|
||||||
|
|
||||||
|
risk_router = APIRouter(prefix="/risk", tags=["risk"])
|
||||||
|
audit_router = APIRouter(prefix="/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/assessments", response_model=RiskAssessmentsResponse)
|
||||||
|
async def list_risk_assessments(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
decision: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
user_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
order_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListRiskAssessmentsCommand = Depends(get_list_risk_assessments_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
return RiskAssessmentsResponse(
|
||||||
|
items=[
|
||||||
|
RiskAssessmentResponse(
|
||||||
|
id=x.id,
|
||||||
|
user_id=x.user_id,
|
||||||
|
order_id=x.order_id,
|
||||||
|
subject_type=x.subject_type,
|
||||||
|
score=x.score,
|
||||||
|
decision=x.decision,
|
||||||
|
reasons=x.reasons,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/summary", response_model=RiskSummaryResponse)
|
||||||
|
async def get_risk_summary(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetRiskSummaryCommand = Depends(get_get_risk_summary_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return RiskSummaryResponse(**item.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
@audit_router.get("/events", response_model=AuditEventsResponse)
|
||||||
|
async def list_audit_events(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
severity: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
action: str | None = Query(default=None, min_length=1, max_length=128),
|
||||||
|
entity_type: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListAuditEventsCommand = Depends(get_list_audit_events_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
return AuditEventsResponse(
|
||||||
|
items=[
|
||||||
|
AuditEventResponse(
|
||||||
|
id=x.id,
|
||||||
|
actor_type=x.actor_type,
|
||||||
|
actor_id=x.actor_id,
|
||||||
|
action=x.action,
|
||||||
|
entity_type=x.entity_type,
|
||||||
|
entity_id=x.entity_id,
|
||||||
|
severity=x.severity,
|
||||||
|
metadata=x.metadata,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
53
src/presentation/schemas/risk.py
Normal file
53
src/presentation/schemas/risk.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None = None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str]
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentsResponse(BaseModel):
|
||||||
|
items: list[RiskAssessmentResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSummaryResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None = None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventsResponse(BaseModel):
|
||||||
|
items: list[AuditEventResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class FunnelResponse(BaseModel):
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
Reference in New Issue
Block a user