562 lines
21 KiB
Python
562 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.crud.server_squad import (
|
|
count_active_users_for_squad,
|
|
get_server_squad_by_uuid,
|
|
)
|
|
|
|
from ..dependencies import get_db_session, require_api_token
|
|
from ..schemas.remnawave import (
|
|
RemnaWaveConnectionStatus,
|
|
RemnaWaveGenericSyncResponse,
|
|
RemnaWaveInboundsResponse,
|
|
RemnaWaveNode,
|
|
RemnaWaveNodeActionRequest,
|
|
RemnaWaveNodeActionResponse,
|
|
RemnaWaveNodeListResponse,
|
|
RemnaWaveNodeStatisticsResponse,
|
|
RemnaWaveNodeUsageResponse,
|
|
RemnaWaveOperationResponse,
|
|
RemnaWaveSquad,
|
|
RemnaWaveSquadActionRequest,
|
|
RemnaWaveSquadCreateRequest,
|
|
RemnaWaveSquadListResponse,
|
|
RemnaWaveSquadMigrationPreviewResponse,
|
|
RemnaWaveSquadMigrationRequest,
|
|
RemnaWaveSquadMigrationResponse,
|
|
RemnaWaveSquadMigrationStats,
|
|
RemnaWaveSquadUpdateRequest,
|
|
RemnaWaveStatusResponse,
|
|
RemnaWaveSystemStatsResponse,
|
|
RemnaWaveSyncFromPanelRequest,
|
|
RemnaWaveUserTrafficResponse,
|
|
)
|
|
|
|
try: # pragma: no cover - импорт может не работать без optional-зависимостей
|
|
from app.services.remnawave_service import ( # type: ignore
|
|
RemnaWaveConfigurationError,
|
|
RemnaWaveService,
|
|
)
|
|
except Exception: # pragma: no cover - при ошибке импорта скрываем функционал
|
|
RemnaWaveConfigurationError = None # type: ignore[assignment]
|
|
RemnaWaveService = None # type: ignore[assignment]
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - только для типов в IDE
|
|
from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType
|
|
else:
|
|
RemnaWaveServiceType = Any
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_service() -> "RemnaWaveServiceType":
|
|
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="RemnaWave сервис недоступен",
|
|
)
|
|
|
|
return RemnaWaveService()
|
|
|
|
|
|
def _ensure_service_configured(service: "RemnaWaveServiceType") -> None:
|
|
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="RemnaWave сервис недоступен",
|
|
)
|
|
|
|
if not service.is_configured:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=service.configuration_error or "RemnaWave API не настроен",
|
|
)
|
|
|
|
|
|
def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
|
|
return RemnaWaveNode(
|
|
uuid=node_data.get("uuid", ""),
|
|
name=node_data.get("name", ""),
|
|
address=node_data.get("address", ""),
|
|
country_code=node_data.get("country_code"),
|
|
is_connected=bool(node_data.get("is_connected")),
|
|
is_disabled=bool(node_data.get("is_disabled")),
|
|
is_node_online=bool(node_data.get("is_node_online")),
|
|
is_xray_running=bool(node_data.get("is_xray_running")),
|
|
users_online=node_data.get("users_online"),
|
|
traffic_used_bytes=node_data.get("traffic_used_bytes"),
|
|
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
|
|
last_status_change=_parse_last_updated(node_data.get("last_status_change")),
|
|
last_status_message=node_data.get("last_status_message"),
|
|
xray_uptime=node_data.get("xray_uptime"),
|
|
is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)),
|
|
traffic_reset_day=node_data.get("traffic_reset_day"),
|
|
notify_percent=node_data.get("notify_percent"),
|
|
consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)),
|
|
cpu_count=node_data.get("cpu_count"),
|
|
cpu_model=node_data.get("cpu_model"),
|
|
total_ram=node_data.get("total_ram"),
|
|
created_at=_parse_last_updated(node_data.get("created_at")),
|
|
updated_at=_parse_last_updated(node_data.get("updated_at")),
|
|
provider_uuid=node_data.get("provider_uuid"),
|
|
)
|
|
|
|
|
|
def _parse_last_updated(value: Any) -> Optional[datetime]:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
@router.get("/status", response_model=RemnaWaveStatusResponse)
|
|
async def get_remnawave_status(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveStatusResponse:
|
|
service = _get_service()
|
|
|
|
connection_info: Optional[RemnaWaveConnectionStatus] = None
|
|
connection_result = await service.test_api_connection()
|
|
|
|
if connection_result:
|
|
connection_info = RemnaWaveConnectionStatus(**connection_result)
|
|
|
|
return RemnaWaveStatusResponse(
|
|
is_configured=service.is_configured,
|
|
configuration_error=service.configuration_error,
|
|
connection=connection_info,
|
|
)
|
|
|
|
|
|
@router.get("/system", response_model=RemnaWaveSystemStatsResponse)
|
|
async def get_system_statistics(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveSystemStatsResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.get_system_statistics()
|
|
if not stats or "system" not in stats:
|
|
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave")
|
|
|
|
stats["last_updated"] = _parse_last_updated(stats.get("last_updated"))
|
|
return RemnaWaveSystemStatsResponse(**stats)
|
|
|
|
|
|
@router.get("/nodes", response_model=RemnaWaveNodeListResponse)
|
|
async def list_nodes(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNodeListResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
nodes = await service.get_all_nodes()
|
|
serialized = [_serialize_node(node) for node in nodes]
|
|
return RemnaWaveNodeListResponse(items=serialized, total=len(serialized))
|
|
|
|
|
|
@router.get("/nodes/realtime", response_model=List[Dict[str, Any]])
|
|
async def get_nodes_realtime_usage(
|
|
_: Any = Security(require_api_token),
|
|
) -> List[Dict[str, Any]]:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
return await service.get_nodes_realtime_usage()
|
|
|
|
|
|
@router.get("/nodes/{node_uuid}", response_model=RemnaWaveNode)
|
|
async def get_node_details(
|
|
node_uuid: str,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNode:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
node = await service.get_node_details(node_uuid)
|
|
if not node:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Нода не найдена")
|
|
return _serialize_node(node)
|
|
|
|
|
|
@router.get("/nodes/{node_uuid}/statistics", response_model=RemnaWaveNodeStatisticsResponse)
|
|
async def get_node_statistics(
|
|
node_uuid: str,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNodeStatisticsResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.get_node_statistics(node_uuid)
|
|
if not stats or not stats.get("node"):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Не удалось получить информацию по ноде")
|
|
|
|
node_data = _serialize_node(stats["node"])
|
|
usage_history = stats.get("usage_history") or []
|
|
realtime = stats.get("realtime")
|
|
last_updated = _parse_last_updated(stats.get("last_updated"))
|
|
|
|
return RemnaWaveNodeStatisticsResponse(
|
|
node=node_data,
|
|
realtime=realtime,
|
|
usage_history=usage_history,
|
|
last_updated=last_updated,
|
|
)
|
|
|
|
|
|
@router.get("/nodes/{node_uuid}/usage", response_model=RemnaWaveNodeUsageResponse)
|
|
async def get_node_usage_range(
|
|
node_uuid: str,
|
|
start: Optional[datetime] = Query(default=None),
|
|
end: Optional[datetime] = Query(default=None),
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNodeUsageResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
end_dt = end or datetime.utcnow()
|
|
start_dt = start or (end_dt - timedelta(days=7))
|
|
|
|
if start_dt >= end_dt:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Некорректный диапазон дат")
|
|
|
|
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
|
|
return RemnaWaveNodeUsageResponse(items=usage or [])
|
|
|
|
|
|
@router.post("/nodes/{node_uuid}/actions", response_model=RemnaWaveNodeActionResponse)
|
|
async def manage_node(
|
|
node_uuid: str,
|
|
payload: RemnaWaveNodeActionRequest,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNodeActionResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
success = await service.manage_node(node_uuid, payload.action)
|
|
detail = None
|
|
if success:
|
|
if payload.action == "enable":
|
|
detail = "Нода включена"
|
|
elif payload.action == "disable":
|
|
detail = "Нода отключена"
|
|
elif payload.action == "restart":
|
|
detail = "Команда перезапуска отправлена"
|
|
else:
|
|
detail = "Не удалось выполнить действие"
|
|
|
|
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
|
|
|
|
|
@router.post("/nodes/restart", response_model=RemnaWaveNodeActionResponse)
|
|
async def restart_all_nodes(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveNodeActionResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
success = await service.restart_all_nodes()
|
|
detail = "Команда перезапуска отправлена" if success else "Не удалось перезапустить ноды"
|
|
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
|
|
|
|
|
@router.get("/squads", response_model=RemnaWaveSquadListResponse)
|
|
async def list_squads(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveSquadListResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
squads = await service.get_all_squads()
|
|
serialized = [RemnaWaveSquad(**squad) for squad in squads]
|
|
return RemnaWaveSquadListResponse(items=serialized, total=len(serialized))
|
|
|
|
|
|
@router.get("/squads/{squad_uuid}", response_model=RemnaWaveSquad)
|
|
async def get_squad_details(
|
|
squad_uuid: str,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveSquad:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
squad = await service.get_squad_details(squad_uuid)
|
|
if not squad:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
|
return RemnaWaveSquad(**squad)
|
|
|
|
|
|
@router.post("/squads", response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_squad(
|
|
payload: RemnaWaveSquadCreateRequest,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveOperationResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
|
|
|
|
success = squad_uuid is not None
|
|
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
|
|
data = {"uuid": squad_uuid} if success else None
|
|
|
|
return RemnaWaveOperationResponse(success=success, detail=detail, data=data)
|
|
|
|
|
|
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
|
|
async def update_squad(
|
|
squad_uuid: str,
|
|
payload: RemnaWaveSquadUpdateRequest,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveOperationResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
success = False
|
|
detail = "Необходимо указать новые данные"
|
|
|
|
if payload.name is not None or payload.inbound_uuids is not None:
|
|
success = await service.update_squad(
|
|
squad_uuid,
|
|
name=payload.name,
|
|
inbounds=payload.inbound_uuids,
|
|
)
|
|
detail = "Сквад обновлен" if success else "Не удалось обновить сквад"
|
|
|
|
return RemnaWaveOperationResponse(success=success, detail=detail)
|
|
|
|
|
|
@router.post("/squads/{squad_uuid}/actions", response_model=RemnaWaveOperationResponse)
|
|
async def squad_actions(
|
|
squad_uuid: str,
|
|
payload: RemnaWaveSquadActionRequest,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveOperationResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
action = payload.action
|
|
success = False
|
|
detail = "Неизвестное действие"
|
|
|
|
if action == "add_all_users":
|
|
success = await service.add_all_users_to_squad(squad_uuid)
|
|
detail = "Пользователи добавлены" if success else "Не удалось добавить пользователей"
|
|
elif action == "remove_all_users":
|
|
success = await service.remove_all_users_from_squad(squad_uuid)
|
|
detail = "Пользователи удалены" if success else "Не удалось удалить пользователей"
|
|
elif action == "delete":
|
|
success = await service.delete_squad(squad_uuid)
|
|
detail = "Сквад удален" if success else "Не удалось удалить сквад"
|
|
elif action == "rename":
|
|
if not payload.name:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать новое имя")
|
|
success = await service.rename_squad(squad_uuid, payload.name)
|
|
detail = "Сквад переименован" if success else "Не удалось переименовать сквад"
|
|
elif action == "update_inbounds":
|
|
if not payload.inbound_uuids:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать inbound_uuids")
|
|
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
|
|
detail = "Инбаунды обновлены" if success else "Не удалось обновить инбаунды"
|
|
|
|
return RemnaWaveOperationResponse(success=success, detail=detail)
|
|
|
|
|
|
@router.get("/inbounds", response_model=RemnaWaveInboundsResponse)
|
|
async def list_inbounds(
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveInboundsResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
inbounds = await service.get_all_inbounds()
|
|
return RemnaWaveInboundsResponse(items=inbounds or [])
|
|
|
|
|
|
@router.get("/users/{telegram_id}/traffic", response_model=RemnaWaveUserTrafficResponse)
|
|
async def get_user_traffic(
|
|
telegram_id: int,
|
|
_: Any = Security(require_api_token),
|
|
) -> RemnaWaveUserTrafficResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.get_user_traffic_stats(telegram_id)
|
|
if not stats:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден в RemnaWave")
|
|
|
|
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
|
|
|
|
|
|
@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse)
|
|
async def preview_squad_migration(
|
|
squad_uuid: str,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveSquadMigrationPreviewResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
squad = await get_server_squad_by_uuid(db, squad_uuid)
|
|
if not squad:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
|
|
|
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
|
|
|
|
return RemnaWaveSquadMigrationPreviewResponse(
|
|
squad_uuid=squad.squad_uuid,
|
|
squad_name=squad.display_name,
|
|
current_users=squad.current_users or 0,
|
|
max_users=squad.max_users,
|
|
users_to_migrate=users_to_migrate,
|
|
)
|
|
|
|
|
|
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
|
|
async def sync_from_panel(
|
|
payload: RemnaWaveSyncFromPanelRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
try:
|
|
stats = await service.sync_users_from_panel(db, payload.mode)
|
|
detail = "Синхронизация из панели выполнена"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
|
except Exception as exc: # pragma: no cover - точный тип зависит от импорта
|
|
if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError):
|
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
raise
|
|
|
|
|
|
@router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse)
|
|
async def sync_to_panel(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.sync_users_to_panel(db)
|
|
detail = "Синхронизация в панель выполнена"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
|
|
|
|
|
@router.post("/sync/subscriptions/validate", response_model=RemnaWaveGenericSyncResponse)
|
|
async def validate_and_fix_subscriptions(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.validate_and_fix_subscriptions(db)
|
|
detail = "Подписки проверены"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
|
|
|
|
|
@router.post("/sync/subscriptions/cleanup", response_model=RemnaWaveGenericSyncResponse)
|
|
async def cleanup_orphaned_subscriptions(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.cleanup_orphaned_subscriptions(db)
|
|
detail = "Очистка завершена"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
|
|
|
|
|
@router.post("/sync/subscriptions/statuses", response_model=RemnaWaveGenericSyncResponse)
|
|
async def sync_subscription_statuses(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
stats = await service.sync_subscription_statuses(db)
|
|
detail = "Статусы подписок синхронизированы"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
|
|
|
|
|
@router.get("/sync/recommendations", response_model=RemnaWaveGenericSyncResponse)
|
|
async def get_sync_recommendations(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveGenericSyncResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
data = await service.get_sync_recommendations(db)
|
|
detail = "Рекомендации получены"
|
|
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
|
|
|
|
|
|
@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse)
|
|
async def migrate_squad(
|
|
payload: RemnaWaveSquadMigrationRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> RemnaWaveSquadMigrationResponse:
|
|
service = _get_service()
|
|
_ensure_service_configured(service)
|
|
|
|
source_uuid = payload.source_uuid.strip()
|
|
target_uuid = payload.target_uuid.strip()
|
|
|
|
if source_uuid == target_uuid:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают")
|
|
|
|
source = await get_server_squad_by_uuid(db, source_uuid)
|
|
if not source:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден")
|
|
|
|
target = await get_server_squad_by_uuid(db, target_uuid)
|
|
if not target:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден")
|
|
|
|
try:
|
|
result = await service.migrate_squad_users(
|
|
db,
|
|
source_uuid=source.squad_uuid,
|
|
target_uuid=target.squad_uuid,
|
|
)
|
|
except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения
|
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
|
|
if not result.get("success"):
|
|
detail = result.get("message") or "Не удалось выполнить переезд"
|
|
return RemnaWaveSquadMigrationResponse(
|
|
success=False,
|
|
detail=detail,
|
|
error=result.get("error"),
|
|
)
|
|
|
|
stats = RemnaWaveSquadMigrationStats(
|
|
source_uuid=source.squad_uuid,
|
|
target_uuid=target.squad_uuid,
|
|
total=result.get("total", 0),
|
|
updated=result.get("updated", 0),
|
|
panel_updated=result.get("panel_updated", 0),
|
|
panel_failed=result.get("panel_failed", 0),
|
|
source_removed=result.get("source_removed", 0),
|
|
target_added=result.get("target_added", 0),
|
|
)
|
|
|
|
detail = result.get("message") or "Переезд выполнен"
|
|
return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats)
|