diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py
index a3f5e62..667b778 100644
--- a/bot/handlers/admin/common.py
+++ b/bot/handlers/admin/common.py
@@ -141,6 +141,9 @@ async def admin_panel_actions_callback_handler(
from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session)
+ elif action == "user_ratings":
+ await admin_stats_handlers.show_user_ratings_handler(
+ callback, i18n_data, settings, session)
elif action == "ads":
from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py
index f136b9c..25ef509 100644
--- a/bot/handlers/admin/statistics.py
+++ b/bot/handlers/admin/statistics.py
@@ -2,6 +2,7 @@ import logging
from aiogram import Router, F, types
from typing import Optional, Dict, List
from datetime import datetime
+import html
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
@@ -10,12 +11,34 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService
-from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
+from bot.keyboards.inline.admin_keyboards import (
+ get_back_to_admin_panel_keyboard,
+ get_back_to_user_management_keyboard,
+)
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_statistics_router")
+def _format_rating_user_label(user_row: Dict[str, object]) -> str:
+ user_id = int(user_row.get("user_id", 0) or 0)
+ username = user_row.get("username")
+ first_name = user_row.get("first_name")
+
+ parts: List[str] = []
+ if username:
+ parts.append(f"@{html.escape(str(username))}")
+ elif first_name:
+ parts.append(html.escape(str(first_name)))
+
+ if not parts:
+ parts.append(f"ID {user_id}")
+ else:
+ parts.append(f"(ID {user_id})")
+
+ return " ".join(parts)
+
+
async def show_statistics_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
@@ -255,3 +278,78 @@ async def show_statistics_handler(callback: types.CallbackQuery,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
break
+
+
+async def show_user_ratings_handler(
+ callback: types.CallbackQuery,
+ i18n_data: dict,
+ settings: Settings,
+ session: AsyncSession,
+):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ if not i18n or not callback.message:
+ await callback.answer("Error displaying ratings.", show_alert=True)
+ return
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
+ await callback.answer()
+
+ top_limit = 10
+ traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
+ invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
+ revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
+
+ text_parts: List[str] = [
+ _("admin_user_ratings_header", top_limit=top_limit),
+ "",
+ f"{_('admin_user_ratings_traffic_title')}",
+ ]
+
+ if traffic_top:
+ for idx, row in enumerate(traffic_top, start=1):
+ traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
+ text_parts.append(
+ _(
+ "admin_user_ratings_traffic_item",
+ rank=idx,
+ user=_format_rating_user_label(row),
+ traffic_gb=f"{traffic_gb:.2f}",
+ )
+ )
+ else:
+ text_parts.append(_("admin_user_ratings_empty"))
+
+ text_parts.extend(["", f"{_('admin_user_ratings_invited_title')}"])
+ if invited_top:
+ for idx, row in enumerate(invited_top, start=1):
+ text_parts.append(
+ _(
+ "admin_user_ratings_invited_item",
+ rank=idx,
+ user=_format_rating_user_label(row),
+ invited_count=int(row.get("invited_count") or 0),
+ )
+ )
+ else:
+ text_parts.append(_("admin_user_ratings_empty"))
+
+ text_parts.extend(["", f"{_('admin_user_ratings_revenue_title')}"])
+ if revenue_top:
+ for idx, row in enumerate(revenue_top, start=1):
+ text_parts.append(
+ _(
+ "admin_user_ratings_revenue_item",
+ rank=idx,
+ user=_format_rating_user_label(row),
+ revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
+ )
+ )
+ else:
+ text_parts.append(_("admin_user_ratings_empty"))
+
+ await callback.message.edit_text(
+ "\n".join(text_parts),
+ reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
+ parse_mode="HTML",
+ )
diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py
index 1b38a66..66ad5ea 100644
--- a/bot/keyboards/inline/admin_keyboards.py
+++ b/bot/keyboards/inline/admin_keyboards.py
@@ -64,10 +64,12 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
callback_data="admin_action:users_search_prompt")
builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management")
+ builder.button(text=_(key="admin_user_ratings_button"),
+ callback_data="admin_action:user_ratings")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
- builder.adjust(2, 1, 1)
+ builder.adjust(2, 2, 1)
return builder.as_markup()
@@ -460,3 +462,21 @@ def get_back_to_admin_panel_keyboard(lang: str,
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
return builder.as_markup()
+
+
+def get_back_to_stats_monitoring_keyboard(lang: str,
+ i18n_instance) -> InlineKeyboardMarkup:
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+ builder = InlineKeyboardBuilder()
+ builder.button(text=_(key="back_to_stats_monitoring_button"),
+ callback_data="admin_section:stats_monitoring")
+ return builder.as_markup()
+
+
+def get_back_to_user_management_keyboard(lang: str,
+ i18n_instance) -> InlineKeyboardMarkup:
+ _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
+ builder = InlineKeyboardBuilder()
+ builder.button(text=_(key="back_to_user_management_button"),
+ callback_data="admin_section:user_management")
+ return builder.as_markup()
diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py
index 0622732..4bbabd3 100644
--- a/db/dal/user_dal.py
+++ b/db/dal/user_dal.py
@@ -5,7 +5,8 @@ from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
-from sqlalchemy import update, delete, func, and_, or_
+from sqlalchemy import update, delete, func, and_, or_, desc
+from sqlalchemy.orm import aliased
from datetime import datetime, timezone
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -360,3 +361,97 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.delete(user)
await session.flush()
return True
+
+
+async def get_top_users_by_traffic_used(
+ session: AsyncSession,
+ *,
+ limit: int = 10,
+) -> List[Dict[str, Any]]:
+ """Return top users by total used traffic across all subscriptions."""
+ safe_limit = max(1, limit)
+
+ total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
+
+ stmt = (
+ select(
+ User.user_id,
+ User.username,
+ User.first_name,
+ total_traffic_used.label("traffic_used_bytes"),
+ )
+ .join(Subscription, Subscription.user_id == User.user_id, isouter=True)
+ .group_by(User.user_id, User.username, User.first_name)
+ .having(total_traffic_used > 0)
+ .order_by(desc("traffic_used_bytes"), User.user_id.asc())
+ .limit(safe_limit)
+ )
+
+ result = await session.execute(stmt)
+ return [dict(row._mapping) for row in result]
+
+
+async def get_top_users_by_referrals_count(
+ session: AsyncSession,
+ *,
+ limit: int = 10,
+) -> List[Dict[str, Any]]:
+ """Return top users by number of invited users."""
+ safe_limit = max(1, limit)
+ referred_user = aliased(User)
+
+ invited_count = func.count(referred_user.user_id)
+
+ stmt = (
+ select(
+ User.user_id,
+ User.username,
+ User.first_name,
+ invited_count.label("invited_count"),
+ )
+ .join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
+ .group_by(User.user_id, User.username, User.first_name)
+ .having(invited_count > 0)
+ .order_by(desc("invited_count"), User.user_id.asc())
+ .limit(safe_limit)
+ )
+
+ result = await session.execute(stmt)
+ return [dict(row._mapping) for row in result]
+
+
+async def get_top_users_by_referral_revenue(
+ session: AsyncSession,
+ *,
+ limit: int = 10,
+) -> List[Dict[str, Any]]:
+ """Return top users by total revenue brought by all invited users."""
+ safe_limit = max(1, limit)
+ referred_user = aliased(User)
+
+ referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
+
+ stmt = (
+ select(
+ User.user_id,
+ User.username,
+ User.first_name,
+ referral_revenue.label("referral_revenue"),
+ )
+ .join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
+ .join(
+ Payment,
+ and_(
+ Payment.user_id == referred_user.user_id,
+ Payment.status == "succeeded",
+ ),
+ isouter=True,
+ )
+ .group_by(User.user_id, User.username, User.first_name)
+ .having(referral_revenue > 0)
+ .order_by(desc("referral_revenue"), User.user_id.asc())
+ .limit(safe_limit)
+ )
+
+ result = await session.execute(stmt)
+ return [dict(row._mapping) for row in result]
diff --git a/locales/en.json b/locales/en.json
index b07d127..f022b37 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -132,6 +132,16 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "No payments found yet.",
"admin_view_payments_button": "💰 Payments",
+ "admin_user_ratings_button": "🏆 User Ratings",
+ "back_to_stats_monitoring_button": "⬅️ To Statistics",
+ "admin_user_ratings_header": "🏆 User Ratings (TOP {top_limit})",
+ "admin_user_ratings_traffic_title": "📊 By used traffic",
+ "admin_user_ratings_invited_title": "👥 By invited users",
+ "admin_user_ratings_revenue_title": "💸 By referral revenue",
+ "admin_user_ratings_traffic_item": "{rank}. {user} — {traffic_gb} GB",
+ "admin_user_ratings_invited_item": "{rank}. {user} — {invited_count}",
+ "admin_user_ratings_revenue_item": "{rank}. {user} — {revenue} RUB",
+ "admin_user_ratings_empty": "No data yet.",
"admin_payments_header": "💰 All Payments",
"admin_no_payments_found": "No payments found.",
"admin_export_payments_csv": "📊 Export CSV",
diff --git a/locales/ru.json b/locales/ru.json
index f110b3d..96bd1c7 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -132,6 +132,16 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "Платежей пока нет.",
"admin_view_payments_button": "💰 Платежи",
+ "admin_user_ratings_button": "🏆 Рейтинг пользователей",
+ "back_to_stats_monitoring_button": "⬅️ К статистике",
+ "admin_user_ratings_header": "🏆 Рейтинг пользователей (ТОП {top_limit})",
+ "admin_user_ratings_traffic_title": "📊 По использованному трафику",
+ "admin_user_ratings_invited_title": "👥 По количеству приглашенных",
+ "admin_user_ratings_revenue_title": "💸 По доходу с приглашенных",
+ "admin_user_ratings_traffic_item": "{rank}. {user} — {traffic_gb} ГБ",
+ "admin_user_ratings_invited_item": "{rank}. {user} — {invited_count}",
+ "admin_user_ratings_revenue_item": "{rank}. {user} — {revenue} RUB",
+ "admin_user_ratings_empty": "Пока нет данных.",
"admin_payments_header": "💰 Все платежи",
"admin_no_payments_found": "Платежи не найдены.",
"admin_export_payments_csv": "📊 Экспорт CSV",