mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Merge pull request #998 from Fr1ngg/ywf16j-bedolaga/add-squad-migration-feature-to-bot
Add RemnaWave squad migration API endpoints
This commit is contained in:
@@ -17,7 +17,14 @@ from sqlalchemy import (
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
ServerSquad,
|
||||
SubscriptionServer,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -495,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
@@ -537,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
|
||||
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
|
||||
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import logging
|
||||
import math
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from app.states import SquadRenameStates, SquadCreateStates
|
||||
from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_remnawave_keyboard, get_sync_options_keyboard,
|
||||
get_node_management_keyboard, get_confirmation_keyboard,
|
||||
get_squad_management_keyboard, get_squad_edit_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_bytes, format_datetime
|
||||
|
||||
@@ -21,6 +27,769 @@ logger = logging.getLogger(__name__)
|
||||
squad_inbound_selections = {}
|
||||
squad_create_data = {}
|
||||
|
||||
MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
|
||||
def _format_migration_server_label(texts, server) -> str:
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
|
||||
if getattr(server, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен")
|
||||
)
|
||||
return texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL",
|
||||
"{name} — 👥 {users} ({status})",
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
stage: str,
|
||||
*,
|
||||
exclude_uuid: str = None,
|
||||
):
|
||||
prefix = "admin_migration_source" if stage == "source" else "admin_migration_target"
|
||||
rows = []
|
||||
has_items = False
|
||||
|
||||
button_template = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON",
|
||||
"🌍 {name} — 👥 {users} ({status})",
|
||||
)
|
||||
|
||||
for squad in squads:
|
||||
if exclude_uuid and squad.squad_uuid == exclude_uuid:
|
||||
continue
|
||||
|
||||
has_items = True
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅")
|
||||
if getattr(squad, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫")
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_template.format(
|
||||
name=squad.display_name,
|
||||
users=squad.current_users,
|
||||
status=status,
|
||||
),
|
||||
callback_data=f"{prefix}_{squad.squad_uuid}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
if page > 1:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{prefix}_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE",
|
||||
"Стр. {page}/{pages}",
|
||||
).format(page=page, pages=total_pages),
|
||||
callback_data="admin_migration_page_info",
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{prefix}_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
rows.append(nav_buttons)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows), has_items
|
||||
|
||||
|
||||
async def _fetch_migration_page(
|
||||
db: AsyncSession,
|
||||
page: int,
|
||||
):
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=max(1, page),
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=page,
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
return squads, page, total_pages
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_squad_migration_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_source)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_source(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_source_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
source_uuid = callback.data.replace("admin_migration_source_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
|
||||
if not server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
source_uuid=server.squad_uuid,
|
||||
source_display=_format_migration_server_label(texts, server),
|
||||
)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=server.squad_uuid,
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=_format_migration_server_label(texts, server))
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_target_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
target_uuid = callback.data.replace("admin_migration_target_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if target_uuid == source_uuid:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD",
|
||||
"Нельзя выбрать тот же сквад.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target_server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
users_to_move = await count_active_users_for_squad(db, source_uuid)
|
||||
|
||||
await state.update_data(
|
||||
target_uuid=target_server.squad_uuid,
|
||||
target_display=_format_migration_server_label(texts, target_server),
|
||||
migration_count=users_to_move,
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.confirming)
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>"),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS",
|
||||
"Проверьте параметры переезда:",
|
||||
),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE",
|
||||
"• Из: {source}",
|
||||
).format(source=source_display),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET",
|
||||
"• В: {target}",
|
||||
).format(target=_format_migration_server_label(texts, target_server)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT",
|
||||
"• Пользователей к переносу: {count}",
|
||||
).format(count=users_to_move),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT",
|
||||
"Подтвердите выполнение операции.",
|
||||
),
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON",
|
||||
"✅ Подтвердить",
|
||||
),
|
||||
callback_data="admin_migration_confirm",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET",
|
||||
"🔄 Изменить сервер назначения",
|
||||
),
|
||||
callback_data="admin_migration_change_target",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def change_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.confirming:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
target_uuid = data.get("target_uuid")
|
||||
|
||||
if not source_uuid or not target_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд..."))
|
||||
|
||||
try:
|
||||
result = await remnawave_service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source_uuid,
|
||||
target_uuid=target_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR",
|
||||
"❌ RemnaWave API не настроен: {error}",
|
||||
).format(error=str(error))
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
target_display = data.get("target_display") or target_uuid
|
||||
|
||||
if not result.get("success"):
|
||||
error_message = result.get("message") or ""
|
||||
error_code = result.get("error") or "unexpected"
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR",
|
||||
"❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
).format(code=error_code, details=error_message)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"),
|
||||
"",
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format(
|
||||
source=source_display
|
||||
),
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format(
|
||||
target=target_display
|
||||
),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL",
|
||||
"Найдено подписок: {count}",
|
||||
).format(count=result.get("total", 0)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED",
|
||||
"Перенесено: {count}",
|
||||
).format(count=result.get("updated", 0)),
|
||||
]
|
||||
|
||||
panel_updated = result.get("panel_updated", 0)
|
||||
panel_failed = result.get("panel_failed", 0)
|
||||
|
||||
if panel_updated:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED",
|
||||
"Обновлено в панели: {count}",
|
||||
).format(count=panel_updated)
|
||||
)
|
||||
if panel_failed:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED",
|
||||
"Не удалось обновить в панели: {count}",
|
||||
).format(count=panel_failed)
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.clear()
|
||||
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED",
|
||||
"❌ Переезд отменен.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_page_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."),
|
||||
show_alert=False,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_remnawave_menu(
|
||||
@@ -1920,6 +2689,15 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
|
||||
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
||||
dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_"))
|
||||
dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_"))
|
||||
dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target")
|
||||
dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm")
|
||||
dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel")
|
||||
dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info")
|
||||
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
|
||||
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
|
||||
|
||||
@@ -674,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_rw_squads"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"),
|
||||
callback_data="admin_rw_migration"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -12,7 +12,8 @@ from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import and_, cast, delete, func, select, update, String
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import (
|
||||
@@ -20,9 +21,16 @@ from app.database.crud.subscription import (
|
||||
update_subscription_usage,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.models import (
|
||||
User, SubscriptionServer, Transaction, ReferralEarning,
|
||||
PromoCodeUse, SubscriptionStatus
|
||||
User,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
ReferralEarning,
|
||||
PromoCodeUse,
|
||||
SubscriptionStatus,
|
||||
ServerSquad,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -481,16 +489,237 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Удален сквад {uuid}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_squad_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
source_uuid: str,
|
||||
target_uuid: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Переносит активных подписок с одного сквада на другой."""
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "same_squad",
|
||||
"message": "Источник и назначение совпадают",
|
||||
}
|
||||
|
||||
source_uuid = source_uuid.strip()
|
||||
target_uuid = target_uuid.strip()
|
||||
|
||||
source_server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
|
||||
if not source_server or not target_server:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "not_found",
|
||||
"message": "Сквады не найдены",
|
||||
}
|
||||
|
||||
subscription_query = (
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{source_uuid}"%'
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(subscription_query)
|
||||
subscriptions = result.scalars().unique().all()
|
||||
|
||||
total_candidates = len(subscriptions)
|
||||
if not subscriptions:
|
||||
logger.info(
|
||||
"🚚 Переезд сквада %s → %s: подходящих подписок не найдено",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"total": 0,
|
||||
"updated": 0,
|
||||
"panel_updated": 0,
|
||||
"panel_failed": 0,
|
||||
}
|
||||
|
||||
exit_stack = AsyncExitStack()
|
||||
panel_updated = 0
|
||||
panel_failed = 0
|
||||
updated_subscriptions = 0
|
||||
source_decrement = 0
|
||||
target_increment = 0
|
||||
|
||||
try:
|
||||
needs_panel_update = any(
|
||||
subscription.user and subscription.user.remnawave_uuid
|
||||
for subscription in subscriptions
|
||||
)
|
||||
|
||||
api = None
|
||||
if needs_panel_update:
|
||||
api = await exit_stack.enter_async_context(self.get_api_client())
|
||||
|
||||
for subscription in subscriptions:
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if source_uuid not in current_squads:
|
||||
continue
|
||||
|
||||
had_target_before = target_uuid in current_squads
|
||||
new_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid
|
||||
]
|
||||
if not had_target_before:
|
||||
new_squads.append(target_uuid)
|
||||
|
||||
if subscription.user and subscription.user.remnawave_uuid:
|
||||
if api is None:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ RemnaWave API недоступен для обновления пользователя %s",
|
||||
subscription.user.telegram_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=subscription.user.remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
)
|
||||
panel_updated += 1
|
||||
except Exception as error:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка обновления сквадов пользователя %s: %s",
|
||||
subscription.user.telegram_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
subscription.connected_squads = new_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
source_decrement += 1
|
||||
if not had_target_before:
|
||||
target_increment += 1
|
||||
|
||||
updated_subscriptions += 1
|
||||
|
||||
link_result = await db.execute(
|
||||
select(SubscriptionServer)
|
||||
.where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == subscription.id,
|
||||
SubscriptionServer.server_squad_id == source_server.id,
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link = link_result.scalars().first()
|
||||
|
||||
if link:
|
||||
if had_target_before:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id
|
||||
== subscription.id,
|
||||
SubscriptionServer.server_squad_id
|
||||
== source_server.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
link.server_squad_id = target_server.id
|
||||
elif not had_target_before:
|
||||
db.add(
|
||||
SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=target_server.id,
|
||||
paid_price_kopeks=0,
|
||||
)
|
||||
)
|
||||
|
||||
if updated_subscriptions:
|
||||
if source_decrement:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == source_server.id)
|
||||
.values(
|
||||
current_users=func.greatest(
|
||||
ServerSquad.current_users - source_decrement,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if target_increment:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == target_server.id)
|
||||
.values(
|
||||
current_users=ServerSquad.current_users + target_increment
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
else:
|
||||
await db.rollback()
|
||||
|
||||
logger.info(
|
||||
"🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
updated_subscriptions,
|
||||
panel_failed,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total_candidates,
|
||||
"updated": updated_subscriptions,
|
||||
"panel_updated": panel_updated,
|
||||
"panel_failed": panel_failed,
|
||||
"source_removed": source_decrement,
|
||||
"target_added": target_increment,
|
||||
}
|
||||
|
||||
except RemnaWaveConfigurationError:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as error:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"❌ Ошибка переезда сквада %s → %s: %s",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
error,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unexpected",
|
||||
"message": str(error),
|
||||
}
|
||||
finally:
|
||||
await exit_stack.aclose()
|
||||
|
||||
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
|
||||
|
||||
@@ -166,6 +166,13 @@ class SquadCreateStates(StatesGroup):
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
|
||||
|
||||
class SquadMigrationStates(StatesGroup):
|
||||
selecting_source = State()
|
||||
selecting_target = State()
|
||||
confirming = State()
|
||||
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
in_promo_submenu = State()
|
||||
|
||||
@@ -6,6 +6,11 @@ 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,
|
||||
@@ -22,6 +27,10 @@ from ..schemas.remnawave import (
|
||||
RemnaWaveSquadActionRequest,
|
||||
RemnaWaveSquadCreateRequest,
|
||||
RemnaWaveSquadListResponse,
|
||||
RemnaWaveSquadMigrationPreviewResponse,
|
||||
RemnaWaveSquadMigrationRequest,
|
||||
RemnaWaveSquadMigrationResponse,
|
||||
RemnaWaveSquadMigrationStats,
|
||||
RemnaWaveSquadUpdateRequest,
|
||||
RemnaWaveStatusResponse,
|
||||
RemnaWaveSystemStatsResponse,
|
||||
@@ -372,6 +381,30 @@ async def get_user_traffic(
|
||||
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,
|
||||
@@ -454,3 +487,58 @@ async def get_sync_recommendations(
|
||||
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)
|
||||
|
||||
@@ -169,3 +169,34 @@ class RemnaWaveGenericSyncResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationPreviewResponse(BaseModel):
|
||||
squad_uuid: str
|
||||
squad_name: str
|
||||
current_users: int
|
||||
max_users: Optional[int] = None
|
||||
users_to_migrate: int
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationRequest(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationStats(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
total: int = 0
|
||||
updated: int = 0
|
||||
panel_updated: int = 0
|
||||
panel_failed: int = 0
|
||||
source_removed: int = 0
|
||||
target_added: int = 0
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
data: Optional[RemnaWaveSquadMigrationStats] = None
|
||||
|
||||
@@ -870,7 +870,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Migration",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Squad migration</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Choose the squad you want to migrate from:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "No squads available. Add new ones or cancel the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Available",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Unavailable",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Page {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Source: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Choose the destination squad:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "No other squads available. Cancel or create new squads.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Squad not found or unavailable.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "You can't choose the same squad.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Review the migration parameters:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• From: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• To: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Users to migrate: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Confirm the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Confirm",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Change destination",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Starting migration...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API is not configured: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Failed to migrate (code: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 New migration",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ Back to Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Migration completed",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Subscriptions matched: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Migrated: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Updated in panel: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Panel update failed: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Migration cancelled.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "This is the current page.",
|
||||
"ADMIN_STATS_USERS": "👥 Users",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_STATS_REVENUE": "💰 Revenue",
|
||||
|
||||
@@ -872,7 +872,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Переезд сквадов</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Выберите сквад, из которого нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Доступен",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Недоступен",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Стр. {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Источник: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Выберите сквад, в который нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Сквад не найден или недоступен.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "Нельзя выбрать тот же сквад.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Проверьте параметры переезда:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• Из: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• В: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Пользователей к переносу: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Подтвердите выполнение операции.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Подтвердить",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Изменить сервер назначения",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Запускаю переезд...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API не настроен: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 Новый переезд",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ В Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Переезд завершен",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Найдено подписок: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Перенесено: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Обновлено в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Не удалось обновить в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Переезд отменен.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "Это текущая страница.",
|
||||
"ADMIN_STATS_USERS": "👥 Пользователи",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_STATS_REVENUE": "💰 Доходы",
|
||||
|
||||
Reference in New Issue
Block a user