diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 3a8a71a..bb71ee9 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -315,13 +315,16 @@ async def show_user_ratings_handler( e_get_me) traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit) + lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_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')}", + f"{_('admin_user_ratings_traffic_month_title')}", ] if traffic_top: @@ -338,6 +341,21 @@ async def show_user_ratings_handler( else: text_parts.append(_("admin_user_ratings_empty")) + text_parts.extend(["", f"{_('admin_user_ratings_traffic_lifetime_title')}"]) + if lifetime_traffic_top: + for idx, row in enumerate(lifetime_traffic_top, start=1): + traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3) + text_parts.append( + _( + "admin_user_ratings_traffic_item", + rank=idx, + user=_format_rating_user_label(row, bot_username), + 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): diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index f56795c..419ef7d 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -18,6 +18,24 @@ from bot.middlewares.i18n import JsonI18n router = Router(name="admin_sync_router") +def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]: + user_traffic = panel_user_data.get("userTraffic") or {} + raw_value = ( + user_traffic.get("lifetimeUsedTrafficBytes") + if isinstance(user_traffic, dict) + else None + ) + if raw_value is None: + raw_value = panel_user_data.get("lifetimeUsedTrafficBytes") + + try: + if raw_value is None: + return None + return int(raw_value) + except (TypeError, ValueError): + return None + + async def perform_sync( panel_service: PanelApiService, session: AsyncSession, @@ -176,6 +194,14 @@ async def perform_sync( f"Updated panel UUID for user {actual_user_id}: {panel_uuid}" ) + lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict) + if ( + lifetime_used is not None + and existing_user.lifetime_used_traffic_bytes != lifetime_used + ): + existing_user.lifetime_used_traffic_bytes = lifetime_used + user_was_updated = True + # Ensure panel description contains Telegram fields try: if panel_uuid and existing_user: diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 7fc97ae..d45993b 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -68,6 +68,20 @@ class SubscriptionService: strategy = traffic_stats.get("trafficLimitStrategy") return used, limit, strategy + def _extract_lifetime_used_traffic( + self, panel_user_data: Dict[str, Any] + ) -> Optional[int]: + traffic_stats = panel_user_data.get("userTraffic") or {} + lifetime = traffic_stats.get("lifetimeUsedTrafficBytes") + if lifetime is None: + lifetime = panel_user_data.get("lifetimeUsedTrafficBytes") + try: + if lifetime is None: + return None + return int(lifetime) + except (TypeError, ValueError): + return None + async def _notify_admin_panel_user_creation_failed(self, user_id: int): if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: return @@ -853,6 +867,17 @@ class SubscriptionService: await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) return None + panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data) + if ( + panel_lifetime_used is not None + and db_user.lifetime_used_traffic_bytes != panel_lifetime_used + ): + await user_dal.update_user( + session, + user_id, + {"lifetime_used_traffic_bytes": panel_lifetime_used}, + ) + if local_active_sub: update_payload_local = {} panel_status = panel_user_data.get("status", "UNKNOWN").upper() diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 4bbabd3..d3d84e0 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -391,6 +391,31 @@ async def get_top_users_by_traffic_used( return [dict(row._mapping) for row in result] +async def get_top_users_by_lifetime_traffic_used( + session: AsyncSession, + *, + limit: int = 10, +) -> List[Dict[str, Any]]: + """Return top users by lifetime used traffic from panel data.""" + safe_limit = max(1, limit) + lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0) + + stmt = ( + select( + User.user_id, + User.username, + User.first_name, + lifetime_used.label("lifetime_used_traffic_bytes"), + ) + .where(lifetime_used > 0) + .order_by(desc("lifetime_used_traffic_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, *, diff --git a/db/migrator.py b/db/migrator.py index 11c4ee0..55a8603 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -112,6 +112,19 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None: ) ) + +def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + if "lifetime_used_traffic_bytes" in columns: + return + + connection.execute( + text( + "ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT" + ) + ) + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -128,6 +141,11 @@ MIGRATIONS: List[Migration] = [ description="Normalize referral codes to uppercase for consistent lookups", upgrade=_migration_0003_normalize_referral_codes, ), + Migration( + id="0004_add_lifetime_used_traffic", + description="Store lifetime traffic usage for users", + upgrade=_migration_0004_add_lifetime_used_traffic, + ), ] diff --git a/db/models.py b/db/models.py index 843300f..67a6908 100644 --- a/db/models.py +++ b/db/models.py @@ -25,6 +25,7 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) + lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True) diff --git a/locales/en.json b/locales/en.json index f022b37..3bcf172 100644 --- a/locales/en.json +++ b/locales/en.json @@ -136,6 +136,8 @@ "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_traffic_month_title": "📊 By used traffic (month)", + "admin_user_ratings_traffic_lifetime_title": "📊 By used traffic (lifetime)", "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", diff --git a/locales/ru.json b/locales/ru.json index 96bd1c7..5bbf47c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -136,6 +136,8 @@ "back_to_stats_monitoring_button": "⬅️ К статистике", "admin_user_ratings_header": "🏆 Рейтинг пользователей (ТОП {top_limit})", "admin_user_ratings_traffic_title": "📊 По использованному трафику", + "admin_user_ratings_traffic_month_title": "📊 По использованному трафику за месяц", + "admin_user_ratings_traffic_lifetime_title": "📊 По использованному трафику за всё время", "admin_user_ratings_invited_title": "👥 По количеству приглашенных", "admin_user_ratings_revenue_title": "💸 По доходу с приглашенных", "admin_user_ratings_traffic_item": "{rank}. {user} — {traffic_gb} ГБ",