From 93353db51151c05b29f4ea3ce7b3015670275ce4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 31 May 2026 00:08:31 +0300 Subject: [PATCH] feat: split subscription stats by access type --- backend/bot/handlers/admin/statistics.py | 8 +- backend/bot/handlers/inline_mode.py | 4 +- backend/db/dal/user_dal.py | 81 +++++++++++++++---- .../src/admin/sections/StatsSection.svelte | 78 +++++++++++++++++- frontend/src/lib/webapp/mockApi.js | 12 ++- locales/en.json | 22 +++-- locales/ru.json | 24 ++++-- tests/test_admin_panel_stats_cache.py | 4 +- tests/test_user_dal.py | 43 ++++++++++ 9 files changed, 243 insertions(+), 33 deletions(-) diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py index 888970f..5e34c8c 100644 --- a/backend/bot/handlers/admin/statistics.py +++ b/backend/bot/handlers/admin/statistics.py @@ -71,11 +71,17 @@ async def show_statistics_handler( f"📊 {_('admin_user_stats_total_label')}: {user_stats['total_users']}" ) # Removed: Active today moved to panel stats + stats_text_parts.append( + f"📡 {_('admin_user_stats_active_subscription_label')}: {user_stats['active_subscriptions']}" # noqa: E501 + ) stats_text_parts.append( f"💳 {_('admin_user_stats_paid_subs_label')}: {user_stats['paid_subscriptions']}" ) stats_text_parts.append( - f"🆓 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}" + f"🧪 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}" + ) + stats_text_parts.append( + f"🎁 {_('admin_user_stats_free_subscription_label')}: {user_stats['free_subscription_users']}" # noqa: E501 ) stats_text_parts.append( f"😴 {_('admin_user_stats_inactive_label')}: {user_stats['inactive_users']}" diff --git a/backend/bot/handlers/inline_mode.py b/backend/bot/handlers/inline_mode.py index c0e5f7e..63421b7 100644 --- a/backend/bot/handlers/inline_mode.py +++ b/backend/bot/handlers/inline_mode.py @@ -166,8 +166,10 @@ async def create_user_stats_result( "inline_user_stats_message", total=user_stats["total_users"], active_today=user_stats["active_today"], + active=user_stats["active_subscriptions"], paid=user_stats["paid_subscriptions"], trial=user_stats["trial_users"], + free=user_stats["free_subscription_users"], inactive=user_stats["inactive_users"], banned=user_stats["banned_users"], referral=user_stats["referral_users"], @@ -179,7 +181,7 @@ async def create_user_stats_result( description=_( "inline_user_stats_description", total=user_stats["total_users"], - active=user_stats["paid_subscriptions"], + active=user_stats["active_subscriptions"], ), input_message_content=InputTextMessageContent( message_text=stats_text, parse_mode="HTML" diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 3bad5d9..8b506e4 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -666,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: active_today = int(user_counts[2] or 0) referral_users = int(user_counts[3] or 0) - subscription_counts_stmt = ( + provider_value = func.lower(func.coalesce(Subscription.provider, "")) + panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, "")) + trial_subscription_condition = or_( + provider_value == "trial", + panel_status_value == "TRIAL", + ) + paid_subscription_condition = and_( + provider_value != "", + provider_value != "trial", + panel_status_value != "TRIAL", + ) + free_subscription_condition = and_( + provider_value == "", + panel_status_value != "TRIAL", + ) + + active_subscription_flags_sq = ( select( - func.count( - func.distinct( - case((Subscription.provider.is_not(None), Subscription.user_id), else_=None) - ) + Subscription.user_id.label("user_id"), + func.max(case((paid_subscription_condition, 1), else_=0)).label( + "has_paid_subscription" ), - func.count( - func.distinct( - case((Subscription.provider.is_(None), Subscription.user_id), else_=None) - ) + func.max(case((trial_subscription_condition, 1), else_=0)).label( + "has_trial_subscription" + ), + func.max(case((free_subscription_condition, 1), else_=0)).label( + "has_free_subscription" ), ) .join(User, Subscription.user_id == User.user_id) @@ -686,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: Subscription.end_date > now, ) ) + .group_by(Subscription.user_id) + .subquery() + ) + + subscription_counts_stmt = select( + func.count(active_subscription_flags_sq.c.user_id), + func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0), + func.coalesce( + func.sum( + case( + ( + active_subscription_flags_sq.c.has_paid_subscription == 0, + active_subscription_flags_sq.c.has_trial_subscription, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case( + ( + and_( + active_subscription_flags_sq.c.has_paid_subscription == 0, + active_subscription_flags_sq.c.has_trial_subscription == 0, + ), + active_subscription_flags_sq.c.has_free_subscription, + ), + else_=0, + ) + ), + 0, + ), ) subscription_counts = (await session.execute(subscription_counts_stmt)).one() - paid_subs_users = int(subscription_counts[0] or 0) - trial_users = int(subscription_counts[1] or 0) + active_subscription_users = int(subscription_counts[0] or 0) + paid_subs_users = int(subscription_counts[1] or 0) + trial_users = int(subscription_counts[2] or 0) + free_subscription_users = int(subscription_counts[3] or 0) - # Inactive users (no active subscription) - inactive_users = total_users - paid_subs_users - trial_users - banned_users + inactive_users = total_users - active_subscription_users return { "total_users": total_users, "banned_users": banned_users, "active_today": active_today, + "active_subscriptions": active_subscription_users, "paid_subscriptions": paid_subs_users, "trial_users": trial_users, + "free_subscription_users": free_subscription_users, "inactive_users": max(0, inactive_users), "referral_users": referral_users, } async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]: - """Return non-banned user IDs who have an active subscription (paid or trial).""" + """Return non-banned user IDs who have any active subscription.""" from datetime import datetime, timezone now = datetime.now(timezone.utc) diff --git a/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte index 67aa380..671d334 100644 --- a/frontend/src/admin/sections/StatsSection.svelte +++ b/frontend/src/admin/sections/StatsSection.svelte @@ -492,7 +492,7 @@ - {#each Array(3) as _, i (i)} + {#each Array(6) as _, i (i)} @@ -690,22 +690,96 @@ + + + {at("stats_label_active_subs", {}, "")} + {users.active_subscriptions ?? 0} + + {users.total_users + ? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100) + : 0}% + + + + + + + + {at("stats_label_paid_subs", {}, "")} {users.paid_subscriptions ?? 0} - {users.trial_users ?? 0} + {users.active_subscriptions + ? Math.round( + ((users.paid_subscriptions ?? 0) / (users.active_subscriptions || 1)) * 100 + ) + : 0}% + + + + + + + {at("stats_label_trial_users", {}, "")} + {users.trial_users ?? 0} + + {users.active_subscriptions + ? Math.round(((users.trial_users ?? 0) / (users.active_subscriptions || 1)) * 100) + : 0}% + + + + + + + {at("stats_label_free_users", {}, "")} + {users.free_subscription_users ?? 0} + + {users.active_subscriptions + ? Math.round( + ((users.free_subscription_users ?? 0) / (users.active_subscriptions || 1)) * 100 + ) + : 0}% + + + + + + + + {at("stats_label_inactive", {}, "")} diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 7a9a95c..670e33f 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1231,7 +1231,17 @@ export async function mockApi(path, options = {}, context = {}) { return { ok: true, currency_symbol: "RUB", - users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, + users: { + total_users: 248, + active_today: 9, + active_subscriptions: 172, + paid_subscriptions: 141, + trial_users: 8, + free_subscription_users: 23, + inactive_users: 76, + banned_users: 3, + referral_users: 34, + }, financial: { today_revenue: 1240, week_revenue: 15800, diff --git a/locales/en.json b/locales/en.json index 0f6f364..c4c340f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -347,8 +347,8 @@ "admin_user_no_logs": "📜 User has no actions", "admin_user_logs_error": "❌ Error loading user actions", "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", - "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", - "inline_user_stats_description": "Total: {total}, Paid: {active}", + "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n📡 With active subscription: {active}\n💳 With paid subscription: {paid}\n🧪 With trial subscription: {trial}\n🎁 With free subscription: {free}\n😴 Without active subscription: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", + "inline_user_stats_description": "Total: {total}, subscribed: {active}", "inline_admin_user_stats_title": "👥 User Statistics", "inline_financial_stats_message": "💰 Financial Statistics\n\n📅 Today: {today:.2f} RUB\n ({today_count} payments)\n📅 Week: {week:.2f} RUB\n📅 Month: {month:.2f} RUB\n🏆 All time: {all_time:.2f} RUB", "inline_admin_financial_stats_title": "💰 Financial Statistics", @@ -488,9 +488,11 @@ "inline_referral_description": "Share referral link to get bonuses", "inline_financial_description": "Today: {today} RUB", "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", + "admin_user_stats_active_subscription_label": "With active subscription", "admin_user_stats_paid_subs_label": "With paid subscription", - "admin_user_stats_trial_label": "On trial period", - "admin_user_stats_inactive_label": "Inactive", + "admin_user_stats_trial_label": "With trial subscription", + "admin_user_stats_free_subscription_label": "With free subscription", + "admin_user_stats_inactive_label": "Without active subscription", "admin_user_stats_banned_label": "Banned", "admin_user_stats_referral_label": "Attracted via referral program", "admin_financial_today_label": "Today", @@ -1098,8 +1100,13 @@ "admin_stats_error": "Failed to load statistics: {error}", "admin_stats_label_users": "Users", "admin_stats_trend_banned": "Banned: {count}", - "admin_stats_label_paid_subs": "Paid Subscriptions", + "admin_stats_label_active_subs": "With active subscription", + "admin_stats_label_paid_subs": "Paid users", + "admin_stats_label_trial_users": "With trial subscription", + "admin_stats_label_free_users": "With free subscription", + "admin_stats_trend_paid": "Paid: {count}", "admin_stats_trend_trials": "Trials: {count}", + "admin_stats_trend_free": "Free: {count}", "admin_stats_label_today_rev": "Today's Revenue", "admin_stats_trend_payments": "{count} payments", "admin_stats_label_week": "This Week", @@ -1145,7 +1152,10 @@ "admin_stats_revenue_avg_check": "Average ticket today: {value}", "admin_stats_revenue_avg_none": "No successful payments today", "admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)", - "admin_stats_card_paid_caption": "Trials shown in the badge", + "admin_stats_card_active_subs_caption": "Paid, trial, and free access combined", + "admin_stats_card_paid_caption": "Paid users separated from trial and free access", + "admin_stats_card_trial_caption": "Only real trial subscriptions", + "admin_stats_card_free_caption": "Manual extensions and bonus access", "admin_stats_card_inactive_caption": "Share of all users is in the badge", "admin_stats_revenue_last_7_calendar": "Last 7 calendar days total", "admin_stats_revenue_prev_7_calendar": "Previous 7 days", diff --git a/locales/ru.json b/locales/ru.json index bb7164f..4270a39 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -347,8 +347,8 @@ "admin_user_no_logs": "📜 У пользователя нет действий", "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", - "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", - "inline_user_stats_description": "Всего: {total}, Платных: {active}", + "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n📡 С активной подпиской: {active}\n💳 С платной подпиской: {paid}\n🧪 С пробной подпиской: {trial}\n🎁 С бесплатной подпиской: {free}\n😴 Без активной подписки: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", + "inline_user_stats_description": "Всего: {total}, с подпиской: {active}", "inline_admin_user_stats_title": "👥 Статистика пользователей", "inline_financial_stats_message": "💰 Финансовая статистика\n\n📅 За сегодня: {today:.2f} RUB\n ({today_count} платежей)\n📅 За неделю: {week:.2f} RUB\n📅 За месяц: {month:.2f} RUB\n🏆 За все время: {all_time:.2f} RUB", "inline_admin_financial_stats_title": "💰 Финансовая статистика", @@ -488,9 +488,11 @@ "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", "inline_financial_description": "Сегодня: {today} RUB", "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", + "admin_user_stats_active_subscription_label": "С активной подпиской", "admin_user_stats_paid_subs_label": "С платной подпиской", - "admin_user_stats_trial_label": "На пробном периоде", - "admin_user_stats_inactive_label": "Неактивных", + "admin_user_stats_trial_label": "С пробной подпиской", + "admin_user_stats_free_subscription_label": "С бесплатной подпиской", + "admin_user_stats_inactive_label": "Без активной подписки", "admin_user_stats_banned_label": "Заблокированных", "admin_user_stats_referral_label": "Привлечено по реферальной программе", "admin_financial_today_label": "За сегодня", @@ -1098,8 +1100,13 @@ "admin_stats_error": "Не удалось загрузить статистику: {error}", "admin_stats_label_users": "Пользователи", "admin_stats_trend_banned": "В бане: {count}", - "admin_stats_label_paid_subs": "Платные подписки", - "admin_stats_trend_trials": "Триалы: {count}", + "admin_stats_label_active_subs": "С активной подпиской", + "admin_stats_label_paid_subs": "Платные пользователи", + "admin_stats_label_trial_users": "С пробной подпиской", + "admin_stats_label_free_users": "С бесплатной подпиской", + "admin_stats_trend_paid": "Платные: {count}", + "admin_stats_trend_trials": "Пробные: {count}", + "admin_stats_trend_free": "Бесплатные: {count}", "admin_stats_label_today_rev": "Доход за день", "admin_stats_trend_payments": "{count} платежей", "admin_stats_label_week": "За неделю", @@ -1145,7 +1152,10 @@ "admin_stats_revenue_avg_check": "Средний чек сегодня: {value}", "admin_stats_revenue_avg_none": "Сегодня без успешных платежей", "admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)", - "admin_stats_card_paid_caption": "Триалы — отдельно в бейдже", + "admin_stats_card_active_subs_caption": "Платные, пробные и бесплатные вместе", + "admin_stats_card_paid_caption": "Платные отдельно от пробных и бесплатных", + "admin_stats_card_trial_caption": "Только реальные пробные подписки", + "admin_stats_card_free_caption": "Ручные начисления и бонусный доступ", "admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже", "admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)", "admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.", diff --git a/tests/test_admin_panel_stats_cache.py b/tests/test_admin_panel_stats_cache.py index e37abb7..74ed0ef 100644 --- a/tests/test_admin_panel_stats_cache.py +++ b/tests/test_admin_panel_stats_cache.py @@ -96,9 +96,11 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase): "total_users": 10, "banned_users": 1, "active_today": 2, + "active_subscriptions": 8, "paid_subscriptions": 7, "trial_users": 1, - "inactive_users": 1, + "free_subscription_users": 0, + "inactive_users": 2, "referral_users": 3, } ) diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 05cb13e..a0358ec 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -27,6 +27,49 @@ class FakeResult: return self._scalar_value return [self._scalar_value] + def one(self): + return self._scalar_value + + +class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): + async def test_get_enhanced_user_statistics_splits_paid_trial_and_free_users(self): + session = SimpleNamespace( + execute=AsyncMock( + side_effect=[ + FakeResult((10, 1, 2, 3)), + FakeResult((8, 4, 2, 2)), + ] + ) + ) + + stats = await user_dal.get_enhanced_user_statistics(session) + + self.assertEqual( + stats, + { + "total_users": 10, + "banned_users": 1, + "active_today": 2, + "active_subscriptions": 8, + "paid_subscriptions": 4, + "trial_users": 2, + "free_subscription_users": 2, + "inactive_users": 2, + "referral_users": 3, + }, + ) + + stmt = session.execute.await_args_list[1].args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("GROUP BY SUBSCRIPTIONS.USER_ID", sql) + self.assertIn("SUBSCRIPTIONS.PROVIDER", sql) + self.assertIn("TRIAL", sql) + class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):