diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py
index 85f1dc58..59bd1e5d 100644
--- a/app/database/crud/server_squad.py
+++ b/app/database/crud/server_squad.py
@@ -1,11 +1,12 @@
import logging
+from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import select, and_, func, update, delete, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
+from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
@@ -232,10 +233,10 @@ async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
-
+
created = 0
updated = 0
- disabled = 0
+ removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
@@ -265,15 +266,101 @@ async def sync_with_remnawave(
)
created += 1
- for uuid, server in existing_servers.items():
- if uuid not in remnawave_uuids and server.is_available:
- server.is_available = False
- disabled += 1
-
+ removed_servers = [
+ server for uuid, server in existing_servers.items()
+ if uuid not in remnawave_uuids
+ ]
+
+ if removed_servers:
+ removed_ids = [server.id for server in removed_servers]
+ removed_uuids = {server.squad_uuid for server in removed_servers}
+
+ subscription_ids_result = await db.execute(
+ select(SubscriptionServer.subscription_id)
+ .where(SubscriptionServer.server_squad_id.in_(removed_ids))
+ )
+ subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
+
+ for server in removed_servers:
+ logger.info(
+ "🗑️ Удаляется сервер %s (UUID: %s)",
+ server.display_name,
+ server.squad_uuid,
+ )
+
+ await db.execute(
+ delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
+ )
+
+ subscriptions_to_update: dict[int, Subscription] = {}
+
+ if subscription_ids:
+ subscriptions_result = await db.execute(
+ select(Subscription).where(Subscription.id.in_(subscription_ids))
+ )
+ for subscription in subscriptions_result.scalars().unique().all():
+ subscriptions_to_update[subscription.id] = subscription
+
+ for squad_uuid in removed_uuids:
+ if not squad_uuid:
+ continue
+
+ extra_result = await db.execute(
+ select(Subscription).where(
+ text("connected_squads::text LIKE :uuid_pattern")
+ ),
+ {"uuid_pattern": f'%"{squad_uuid}"%'}
+ )
+
+ for subscription in extra_result.scalars().unique().all():
+ subscriptions_to_update[subscription.id] = subscription
+
+ cleaned_subscriptions = 0
+
+ for subscription in subscriptions_to_update.values():
+ current_squads = list(subscription.connected_squads or [])
+ if not current_squads:
+ continue
+
+ filtered_squads = [
+ squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
+ ]
+
+ if len(filtered_squads) != len(current_squads):
+ subscription.connected_squads = filtered_squads
+ subscription.updated_at = datetime.utcnow()
+ cleaned_subscriptions += 1
+
+ await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
+ removed = len(removed_servers)
+
+ if cleaned_subscriptions:
+ logger.info(
+ "🧹 Обновлены подписки после удаления серверов: %s",
+ cleaned_subscriptions,
+ )
+
await db.commit()
-
- logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
- return created, updated, disabled
+
+ logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
+ return created, updated, removed
+
+
+async def get_server_connected_users(
+ db: AsyncSession,
+ server_id: int
+) -> List[User]:
+
+ result = await db.execute(
+ select(User)
+ .join(Subscription, Subscription.user_id == User.id)
+ .join(SubscriptionServer, SubscriptionServer.subscription_id == Subscription.id)
+ .where(SubscriptionServer.server_squad_id == server_id)
+ .options(selectinload(User.subscription))
+ .order_by(User.id)
+ )
+
+ return result.scalars().unique().all()
def _generate_display_name(original_name: str) -> str:
diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py
index 9edc3af0..c0233399 100644
--- a/app/handlers/admin/servers.py
+++ b/app/handlers/admin/servers.py
@@ -1,3 +1,4 @@
+import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
+ get_server_connected_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
@@ -73,6 +75,11 @@ def _build_server_edit_view(server):
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
),
],
+ [
+ types.InlineKeyboardButton(
+ text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
+ ),
+ ],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
@@ -276,7 +283,7 @@ async def sync_servers_with_remnawave(
)
return
- created, updated, disabled = await sync_with_remnawave(db, squads)
+ created, updated, removed = await sync_with_remnawave(db, squads)
await cache.delete_pattern("available_countries*")
@@ -286,7 +293,7 @@ async def sync_servers_with_remnawave(
📊 Результаты:
• Создано новых серверов: {created}
• Обновлено существующих: {updated}
-• Отключено неактивных: {disabled}
+• Удалено отсутствующих: {removed}
• Всего обработано: {len(squads)}
ℹ️ Новые серверы созданы как недоступные.
@@ -314,7 +321,7 @@ async def sync_servers_with_remnawave(
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
-
+
await callback.answer()
@@ -343,6 +350,89 @@ async def show_server_edit_menu(
await callback.answer()
+@admin_required
+@error_handler
+async def show_server_users(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+
+ server_id = int(callback.data.split('_')[-1])
+ server = await get_server_squad_by_id(db, server_id)
+
+ if not server:
+ await callback.answer("❌ Сервер не найден!", show_alert=True)
+ return
+
+ users = await get_server_connected_users(db, server_id)
+
+ safe_name = html.escape(server.display_name or "—")
+ safe_uuid = html.escape(server.squad_uuid or "—")
+
+ header = [
+ "🌐 Пользователи сервера",
+ "",
+ f"• Сервер: {safe_name}",
+ f"• UUID: {safe_uuid}",
+ f"• Подключений: {len(users)}",
+ "",
+ ]
+
+ text = "\n".join(header)
+
+ if users:
+ lines = []
+ for index, user in enumerate(users, 1):
+ subscription_status = (
+ user.subscription.status_display
+ if user.subscription
+ else "❌ Нет подписки"
+ )
+ safe_user_name = html.escape(user.full_name)
+ safe_status = html.escape(subscription_status)
+ lines.append(
+ f"{index}. {safe_user_name} — {safe_status}"
+ )
+
+ text += "\n" + "\n".join(lines)
+ else:
+ text += "Пользователи не найдены."
+
+ keyboard: list[list[types.InlineKeyboardButton]] = []
+
+ for user in users:
+ display_name = user.full_name
+ if len(display_name) > 30:
+ display_name = display_name[:27] + "..."
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=f"👤 {display_name}",
+ callback_data=f"admin_user_manage_{user.id}",
+ )
+ ])
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
+ )
+ ])
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text="⬅️ К списку", callback_data="admin_servers_list"
+ )
+ ])
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
+ parse_mode="HTML",
+ )
+
+ await callback.answer()
+
+
@admin_required
@error_handler
async def toggle_server_availability(
@@ -1105,6 +1195,7 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
+ dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))