diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py
index 88b52008..f1dccd7f 100644
--- a/app/database/crud/server_squad.py
+++ b/app/database/crud/server_squad.py
@@ -17,14 +17,7 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.database.models import (
- PromoGroup,
- ServerSquad,
- SubscriptionServer,
- Subscription,
- SubscriptionStatus,
- User,
-)
+from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
@@ -502,10 +495,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)
@@ -544,25 +537,6 @@ 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]
diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py
index d6167a27..ec5b4629 100644
--- a/app/handlers/admin/remnawave.py
+++ b/app/handlers/admin/remnawave.py
@@ -1,24 +1,18 @@
import logging
-import math
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
-from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
+from app.states import SquadRenameStates, SquadCreateStates
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, RemnaWaveConfigurationError
+from app.services.remnawave_service import RemnaWaveService
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_bytes, format_datetime
@@ -27,769 +21,6 @@ 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", "🚚 Переезд сквадов")
- + "\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", "🚚 Переезд сквадов")
- + "\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", "🚚 Переезд сквадов")
- + "\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", "🚚 Переезд сквадов")
- + "\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", "🚚 Переезд сквадов"),
- "",
- 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", "🚚 Переезд сквадов")
- + "\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(
@@ -2689,15 +1920,6 @@ 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_"))
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index a1d9e3a8..076fe30e 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -674,12 +674,6 @@ 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", "📈 Трафик"),
diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py
index 84e90c6f..6ec15eea 100644
--- a/app/services/remnawave_service.py
+++ b/app/services/remnawave_service.py
@@ -1,7 +1,7 @@
import logging
import os
import re
-from contextlib import AsyncExitStack, asynccontextmanager
+from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
@@ -12,8 +12,7 @@ from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
-from sqlalchemy import and_, cast, delete, func, select, update, String
-from sqlalchemy.orm import selectinload
+from sqlalchemy import delete
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 (
@@ -21,16 +20,9 @@ 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,
- Subscription,
- SubscriptionServer,
- Transaction,
- ReferralEarning,
- PromoCodeUse,
- SubscriptionStatus,
- ServerSquad,
+ User, SubscriptionServer, Transaction, ReferralEarning,
+ PromoCodeUse, SubscriptionStatus
)
logger = logging.getLogger(__name__)
@@ -489,237 +481,16 @@ 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}
diff --git a/app/states.py b/app/states.py
index 0d10a629..b7eb5765 100644
--- a/app/states.py
+++ b/app/states.py
@@ -166,13 +166,6 @@ 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()
diff --git a/locales/en.json b/locales/en.json
index 217035a6..3444c456 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -870,42 +870,7 @@
"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": "🚚 Squad migration",
- "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",
diff --git a/locales/ru.json b/locales/ru.json
index 2348dd4b..47242a4d 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -872,42 +872,7 @@
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
- "ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд",
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
- "ADMIN_SQUAD_MIGRATION_TITLE": "🚚 Переезд сквадов",
- "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": "💰 Доходы",