From 5247092ca29b265d6eb6623d13c61785d7ec28ec Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 9 Feb 2026 10:03:55 +0300 Subject: [PATCH] Add users rating feature --- bot/handlers/admin/common.py | 3 + bot/handlers/admin/statistics.py | 100 +++++++++++++++++++++++- bot/keyboards/inline/admin_keyboards.py | 22 +++++- db/dal/user_dal.py | 97 ++++++++++++++++++++++- locales/en.json | 10 +++ locales/ru.json | 10 +++ 6 files changed, 239 insertions(+), 3 deletions(-) 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",