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:
Egor
2025-10-10 02:10:50 +03:00
committed by GitHub
9 changed files with 1248 additions and 13 deletions

View File

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

View File

@@ -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_"))

View File

@@ -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", "📈 Трафик"),

View File

@@ -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}

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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": "💰 Доходы",