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