diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py
index 85f1dc58..15af5746 100644
--- a/app/database/crud/server_squad.py
+++ b/app/database/crud/server_squad.py
@@ -1,11 +1,17 @@
import logging
from typing import Iterable, List, Optional, Sequence, Tuple
-from sqlalchemy import select, and_, func, update, delete, text
+from sqlalchemy import select, 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__)
@@ -228,14 +234,48 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
return True
+async def get_server_users(
+ db: AsyncSession,
+ server_id: int,
+) -> List[dict]:
+
+ result = await db.execute(
+ select(User, Subscription, SubscriptionServer)
+ .join(Subscription, Subscription.user_id == User.id)
+ .join(
+ SubscriptionServer,
+ SubscriptionServer.subscription_id == Subscription.id,
+ )
+ .where(SubscriptionServer.server_squad_id == server_id)
+ .order_by(User.id)
+ )
+
+ users_info: List[dict] = []
+ for user, subscription, link in result.all():
+ users_info.append(
+ {
+ "user_id": user.id,
+ "telegram_id": user.telegram_id,
+ "username": user.username,
+ "first_name": user.first_name,
+ "subscription_id": subscription.id,
+ "subscription_status": subscription.status,
+ "subscription_end_date": subscription.end_date,
+ "connected_at": link.connected_at,
+ }
+ )
+
+ return users_info
+
+
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 +305,64 @@ 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
-
+ missing_servers = [
+ server for uuid, server in existing_servers.items()
+ if uuid not in remnawave_uuids
+ ]
+
+ if missing_servers:
+ missing_ids = [server.id for server in missing_servers]
+ missing_uuids = [server.squad_uuid for server in missing_servers]
+
+ connections_count_result = await db.execute(
+ select(func.count(SubscriptionServer.id)).where(
+ SubscriptionServer.server_squad_id.in_(missing_ids)
+ )
+ )
+ removed_connections = connections_count_result.scalar() or 0
+
+ await db.execute(
+ delete(SubscriptionServer).where(
+ SubscriptionServer.server_squad_id.in_(missing_ids)
+ )
+ )
+
+ if removed_connections:
+ logger.info(
+ "🔁 Удалено %s привязок подписок к отсутствующим серверам",
+ removed_connections,
+ )
+
+ subscriptions_result = await db.execute(
+ select(Subscription).where(Subscription.connected_squads.isnot(None))
+ )
+
+ for subscription in subscriptions_result.scalars().all():
+ current_squads = list(subscription.connected_squads or [])
+ if not current_squads:
+ continue
+
+ updated_squads = [
+ squad_uuid for squad_uuid in current_squads
+ if squad_uuid not in missing_uuids
+ ]
+
+ if updated_squads != current_squads:
+ subscription.connected_squads = updated_squads
+
+ for server in missing_servers:
+ logger.info(
+ "🗑️ Удаляем сервер %s (UUID: %s) — отсутствует в RemnaWave",
+ server.display_name,
+ server.squad_uuid,
+ )
+ await db.delete(server)
+ removed += 1
+
await db.commit()
-
- logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
- return created, updated, disabled
+
+ logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
+ return created, updated, removed
def _generate_display_name(original_name: str) -> str:
diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py
index 9edc3af0..1978236c 100644
--- a/app/handlers/admin/servers.py
+++ b/app/handlers/admin/servers.py
@@ -15,6 +15,7 @@ from app.database.crud.server_squad import (
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
+ get_server_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
@@ -81,6 +82,11 @@ def _build_server_edit_view(server):
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
),
],
+ [
+ types.InlineKeyboardButton(
+ text="👥 Пользователи", callback_data=f"admin_server_users_{server.id}"
+ ),
+ ],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
@@ -276,7 +282,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 +292,7 @@ async def sync_servers_with_remnawave(
📊 Результаты:
• Создано новых серверов: {created}
• Обновлено существующих: {updated}
-• Отключено неактивных: {disabled}
+• Удалено отсутствующих: {removed}
• Всего обработано: {len(squads)}
ℹ️ Новые серверы созданы как недоступные.
@@ -1019,9 +1025,119 @@ async def save_server_promo_groups(
reply_markup=keyboard,
parse_mode="HTML",
)
+
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_users(db, server_id)
+
+ status_emojis = {
+ "active": "✅",
+ "trial": "🧪",
+ "expired": "⏰",
+ "disabled": "⛔",
+ }
+ status_labels = {
+ "active": "АКТИВНА",
+ "trial": "TRIAL",
+ "expired": "ИСТЕКЛА",
+ "disabled": "ОТКЛЮЧЕНА",
+ }
+
+ text_lines = [
+ "👥 Пользователи сервера",
+ "",
+ f"Сервер: {server.display_name}",
+ f"UUID: {server.squad_uuid}",
+ "",
+ ]
+
+ keyboard_rows = []
+ max_buttons = 50
+
+ if not users:
+ text_lines.append("На этот сервер пока никто не подключен.")
+ else:
+ text_lines.append(f"Всего пользователей: {len(users)}")
+ text_lines.append("Нажмите на пользователя ниже, чтобы открыть управление.")
+ text_lines.append("")
+
+ preview_limit = 10
+ for user_info in users[:preview_limit]:
+ name_parts = []
+ if user_info.get("username"):
+ name_parts.append(f"@{user_info['username']}")
+ if user_info.get("first_name"):
+ name_parts.append(user_info["first_name"])
+ display_name = " ".join(name_parts) or str(user_info["telegram_id"])
+
+ status = (user_info.get("subscription_status") or "unknown").lower()
+ status_text = status_labels.get(status, status.upper())
+ emoji = status_emojis.get(status, "ℹ️")
+
+ text_lines.append(f"• {display_name} — {emoji} {status_text}")
+
+ if len(users) > preview_limit:
+ remaining = len(users) - preview_limit
+ text_lines.append(f"… и ещё {remaining} пользователей")
+
+ for user_info in users[:max_buttons]:
+ if user_info.get("username"):
+ label = f"@{user_info['username']}"
+ elif user_info.get("first_name"):
+ label = user_info["first_name"]
+ else:
+ label = str(user_info["telegram_id"])
+
+ status = (user_info.get("subscription_status") or "unknown").lower()
+ emoji = status_emojis.get(status, "ℹ️")
+ button_text = f"{emoji} {label}"[:64]
+
+ keyboard_rows.append(
+ [
+ types.InlineKeyboardButton(
+ text=button_text,
+ callback_data=f"admin_user_manage_{user_info['user_id']}",
+ )
+ ]
+ )
+
+ if len(users) > max_buttons:
+ text_lines.append(
+ f"⚠️ Показаны первые {max_buttons} пользователей. Используйте фильтры в разделе пользователей для подробностей."
+ )
+
+ keyboard_rows.append(
+ [
+ types.InlineKeyboardButton(
+ text="⬅️ Назад", callback_data=f"admin_server_edit_{server.id}"
+ )
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(text_lines),
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
@admin_required
@error_handler
async def sync_server_user_counts_handler(
@@ -1105,6 +1221,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_"))