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} ГБ",