Revert "Add Remnawave squad migration admin workflow"

This commit is contained in:
Egor
2025-10-10 02:09:35 +03:00
committed by GitHub
parent 09a46fccad
commit 4120d516b4
7 changed files with 13 additions and 1129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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