From 6a40d0c9ce49da6ce48fe8e1ba3f5ebbc020f09e Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 15:05:10 +0300 Subject: [PATCH 1/4] feat: add expired subscription broadcast target --- .../bot/app/web/admin_api_impl/broadcast.py | 4 +- backend/bot/app/web/admin_api_impl/users.py | 30 ++++++++- backend/bot/handlers/admin/broadcast.py | 4 +- .../bot/keyboards/inline/admin_keyboards.py | 10 ++- backend/db/dal/user_dal.py | 62 +++++++++++++++++++ .../src/admin/sections/StatsSection.svelte | 6 +- .../src/lib/admin/stores/broadcastStore.js | 1 + frontend/src/lib/webapp/demoDataset.js | 1 + frontend/src/lib/webapp/mockApi.js | 1 + locales/en.json | 3 + locales/ru.json | 3 + tests/test_admin_panel_stats_cache.py | 1 + tests/test_user_dal.py | 38 ++++++++++++ 13 files changed, 156 insertions(+), 8 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index 97f972e..b19bc0a 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive"}: + if target not in {"all", "active", "inactive", "expired"}: target = "all" queue_manager = get_queue_manager() @@ -22,6 +22,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) + elif target == "expired": + user_ids = await user_dal.get_user_ids_with_expired_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 29ee2e2..8bd2ae8 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -16,6 +16,7 @@ import hashlib from html import escape as html_escape from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.orm import aliased from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches from bot.infra.redis import cache_delete_pattern, redis_key @@ -538,9 +539,34 @@ def _user_panel_status_condition(panel_status: str): normalized_status == "active", blank_status & Subscription.is_active.is_(True) ) elif status == "expired": - status_cond = or_( - normalized_status == "expired", blank_status & Subscription.is_active.is_(False) + now = datetime.now(timezone.utc) + expired_subs = aliased(Subscription) + active_subs = aliased(Subscription) + expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, "")) + expired_blank_status = or_( + expired_subs.status_from_panel.is_(None), + expired_subs.status_from_panel == "", ) + expired_condition = or_( + expired_status == "expired", + expired_blank_status & expired_subs.is_active.is_(False), + expired_subs.end_date <= now, + ) + expired_exists = ( + select(expired_subs.subscription_id) + .where(expired_subs.user_id == User.user_id, expired_condition) + .exists() + ) + active_exists = ( + select(active_subs.subscription_id) + .where( + active_subs.user_id == User.user_id, + active_subs.is_active.is_(True), + active_subs.end_date > now, + ) + .exists() + ) + return and_(expired_exists, ~active_exists) else: status_cond = normalized_status == "limited" diff --git a/backend/bot/handlers/admin/broadcast.py b/backend/bot/handlers/admin/broadcast.py index 73b3f31..0e0754b 100644 --- a/backend/bot/handlers/admin/broadcast.py +++ b/backend/bot/handlers/admin/broadcast.py @@ -155,7 +155,7 @@ async def change_broadcast_target_handler( return new_target = callback.data.split(":")[1] - if new_target not in {"all", "active", "inactive"}: + if new_target not in {"all", "active", "inactive", "expired"}: await callback.answer("Unknown target.", show_alert=True) return @@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler( user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) + elif target == "expired": + user_ids = await user_dal.get_user_ids_with_expired_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) diff --git a/backend/bot/keyboards/inline/admin_keyboards.py b/backend/bot/keyboards/inline/admin_keyboards.py index 2b9530d..fdfd419 100644 --- a/backend/bot/keyboards/inline/admin_keyboards.py +++ b/backend/bot/keyboards/inline/admin_keyboards.py @@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard( _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - # Row: target selection (all / active / inactive) + # Row: target selection (all / active / inactive / expired) target_all_label = _(key="broadcast_target_all_button") target_active_label = _(key="broadcast_target_active_button") target_inactive_label = _(key="broadcast_target_inactive_button") + target_expired_label = _(key="broadcast_target_expired_button") # Highlight current selection with a prefix def mark_selected(label: str, is_selected: bool) -> str: @@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard( text=mark_selected(target_inactive_label, target == "inactive"), callback_data="broadcast_target:inactive", ) - builder.adjust(3) + builder.button( + text=mark_selected(target_expired_label, target == "expired"), + callback_data="broadcast_target:expired", + ) # Row: confirmation builder.button( @@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard( builder.button( text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel" ) - builder.adjust(2) + builder.adjust(2, 2, 2) return builder.as_markup() diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index cc2102e..4d0308b 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -777,6 +777,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: free_subscription_users = int(subscription_counts[3] or 0) inactive_users = total_users - active_subscription_users + expired_subscription_users = await count_users_with_expired_subscription(session) return { "total_users": total_users, @@ -787,6 +788,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: "trial_users": trial_users, "free_subscription_users": free_subscription_users, "inactive_users": max(0, inactive_users), + "expired_subscription_users": expired_subscription_users, "referral_users": referral_users, } @@ -841,6 +843,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +def _expired_subscription_exists_for_user(now: datetime): + expired_subs = aliased(Subscription) + normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) + blank_status = or_( + expired_subs.status_from_panel.is_(None), + expired_subs.status_from_panel == "", + ) + expired_condition = or_( + normalized_status == "expired", + blank_status & expired_subs.is_active.is_(False), + expired_subs.end_date <= now, + ) + + return ( + select(expired_subs.subscription_id) + .where(expired_subs.user_id == User.user_id, expired_condition) + .exists() + ) + + +def _active_subscription_exists_for_user(now: datetime): + active_subs = aliased(Subscription) + return ( + select(active_subs.subscription_id) + .where( + active_subs.user_id == User.user_id, + active_subs.is_active == True, + active_subs.end_date > now, + ) + .exists() + ) + + +async def count_users_with_expired_subscription(session: AsyncSession) -> int: + """Count users who have an expired subscription and no currently active subscription.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + stmt = select(func.count(User.user_id)).where( + _expired_subscription_exists_for_user(now), + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs with an expired subscription and no active one.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + stmt = select(User.user_id).where( + User.is_banned == False, + _expired_subscription_exists_for_user(now), + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return result.scalars().all() + + async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool: """Completely remove a user and all dependent records from the database. diff --git a/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte index ed9d30e..353a717 100644 --- a/frontend/src/admin/sections/StatsSection.svelte +++ b/frontend/src/admin/sections/StatsSection.svelte @@ -728,7 +728,11 @@ diff --git a/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js index 33fea5a..7e7e208 100644 --- a/frontend/src/lib/admin/stores/broadcastStore.js +++ b/frontend/src/lib/admin/stores/broadcastStore.js @@ -12,6 +12,7 @@ export function createBroadcastStore({ api, onToast, at }) { { value: "all", label: at("broadcast_target_all", {}, "Все активные") }, { value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, + { value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") }, ]; async function runBroadcast() { diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index d24ddb4..627b6e8 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -82665,6 +82665,7 @@ export const DEMO_DATASET = { paid_subscriptions: 177, trial_users: 0, inactive_users: 193, + expired_subscription_users: 97, referral_users: 106, }, financial: { diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 058d813..8f0c816 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1278,6 +1278,7 @@ export async function mockApi(path, options = {}, context = {}) { trial_users: 8, free_subscription_users: 23, inactive_users: 76, + expired_subscription_users: 31, banned_users: 3, referral_users: 34, }, diff --git a/locales/en.json b/locales/en.json index ab61a77..b37f5f3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -179,6 +179,7 @@ "broadcast_target_all_button": "👥 All", "broadcast_target_active_button": "✅ Active", "broadcast_target_inactive_button": "⌛ Inactive", + "broadcast_target_expired_button": "⏰ Expired", "confirm_broadcast_send_button": "✅ Send", "admin_broadcast_sending_started": "Starting broadcast...", "admin_broadcast_error_no_message": "Error: no message to broadcast.", @@ -1127,6 +1128,7 @@ "admin_broadcast_target_all": "All active", "admin_broadcast_target_active": "With subscription", "admin_broadcast_target_inactive": "No subscription", + "admin_broadcast_target_expired": "Expired subscription", "admin_expired_at": "Expired {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Failed to load statistics: {error}", @@ -1154,6 +1156,7 @@ "admin_stats_trend_referrals": "Referrals: {count}", "admin_stats_label_inactive": "No active subscription", "admin_stats_trend_new_today": "Registrations today: {count}", + "admin_stats_trend_expired_subscriptions": "Expired subscriptions: {count}", "admin_stats_section_revenue": "Revenue", "admin_stats_section_revenue_hint": "Succeeded payments, shop currency", "admin_stats_revenue_chart_title": "Daily revenue (UTC)", diff --git a/locales/ru.json b/locales/ru.json index b4d5224..cdc1f9e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -179,6 +179,7 @@ "broadcast_target_all_button": "👥 Все", "broadcast_target_active_button": "✅ Активные", "broadcast_target_inactive_button": "⌛ Неактивные", + "broadcast_target_expired_button": "⏰ Просроченные", "confirm_broadcast_send_button": "✅ Отправить", "admin_broadcast_sending_started": "Начинаю рассылку...", "admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.", @@ -1127,6 +1128,7 @@ "admin_broadcast_target_all": "Все активные", "admin_broadcast_target_active": "С подпиской", "admin_broadcast_target_inactive": "Без подписки", + "admin_broadcast_target_expired": "С просроченной подпиской", "admin_expired_at": "Истекла {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Не удалось загрузить статистику: {error}", @@ -1154,6 +1156,7 @@ "admin_stats_trend_referrals": "Рефералы: {count}", "admin_stats_label_inactive": "Без активной подписки", "admin_stats_trend_new_today": "Регистраций сегодня: {count}", + "admin_stats_trend_expired_subscriptions": "С просроченной подпиской: {count}", "admin_stats_section_revenue": "Доходы", "admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина", "admin_stats_revenue_chart_title": "Выручка по дням (UTC)", diff --git a/tests/test_admin_panel_stats_cache.py b/tests/test_admin_panel_stats_cache.py index 74ed0ef..d5eb82c 100644 --- a/tests/test_admin_panel_stats_cache.py +++ b/tests/test_admin_panel_stats_cache.py @@ -101,6 +101,7 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase): "trial_users": 1, "free_subscription_users": 0, "inactive_users": 2, + "expired_subscription_users": 1, "referral_users": 3, } ) diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 873e587..ede5e3f 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -41,6 +41,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): side_effect=[ FakeResult((10, 1, 2, 3)), FakeResult((8, 4, 2, 2)), + FakeResult(3), ] ) ) @@ -58,6 +59,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): "trial_users": 2, "free_subscription_users": 2, "inactive_users": 2, + "expired_subscription_users": 3, "referral_users": 3, }, ) @@ -211,6 +213,42 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("LEFT OUTER JOIN", sql) self.assertIn("IS NULL", sql) + async def test_count_users_with_expired_subscription_excludes_currently_active(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(4))) + + result = await user_dal.count_users_with_expired_subscription(session) + + self.assertEqual(result, 4) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("EXISTS", sql) + self.assertIn("EXPIRED", sql) + self.assertIn("END_DATE <=", sql) + self.assertIn("NOT (EXISTS", sql) + self.assertNotIn("USERS.IS_BANNED", sql) + + async def test_get_user_ids_with_expired_subscription_excludes_banned_users(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult([2, 3]))) + + result = await user_dal.get_user_ids_with_expired_subscription(session) + + self.assertEqual(result, [2, 3]) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("USERS.IS_BANNED = FALSE", sql) + self.assertIn("EXPIRED", sql) + self.assertIn("NOT (EXISTS", sql) + async def test_merge_users_uses_bulk_updates_for_related_tables(self): source = SimpleNamespace( user_id=1, From d7840d3a861d67e300f1f919c15003dad2ba2a01 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 15:52:54 +0300 Subject: [PATCH 2/4] feat: expose admin user list metrics --- backend/bot/app/web/admin_api_impl/users.py | 151 ++++++++++++++++++++ tests/test_admin_users_list_metrics.py | 116 +++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/test_admin_users_list_metrics.py diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 8bd2ae8..98703ff 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -128,12 +128,17 @@ async def _load_admin_users_list_payload_uncached( active_subs = await _bulk_active_subscriptions_for_users( session, [u.user_id for u in users] ) + payment_summaries = await _bulk_user_payment_summaries( + session, [u.user_id for u in users] + ) + referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users]) serialized = [] for user in users: payload = _serialize_user(user) status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None} payload["panel_status"] = status_payload.get("status") + payload["subscription_expires_at"] = status_payload.get("end_date") if status_payload.get("status") == "expired" and status_payload.get("end_date"): payload["panel_status_expired_at"] = status_payload["end_date"] payload["avatar_url"] = ( @@ -142,6 +147,11 @@ async def _load_admin_users_list_payload_uncached( else None ) payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id)) + payment_summary = payment_summaries.get(user.user_id) or {} + payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0) + payload["payments_count"] = int(payment_summary.get("count") or 0) + payload["payments_currency"] = payment_summary.get("currency") + payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0) serialized.append(payload) return { @@ -365,6 +375,88 @@ async def _bulk_active_subscriptions_for_users( return out +def _user_payment_summary_sq(): + return ( + select( + Payment.user_id.label("user_id"), + sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"), + sa_func.count(Payment.payment_id).label("payments_count"), + ) + .where(Payment.status == "succeeded") + .group_by(Payment.user_id) + .subquery(name="user_payment_summary") + ) + + +def _user_referral_count_sq(): + referred_user = aliased(User) + return ( + select( + referred_user.referred_by_id.label("user_id"), + sa_func.count(referred_user.user_id).label("invited_users_count"), + ) + .where(referred_user.referred_by_id.is_not(None)) + .group_by(referred_user.referred_by_id) + .subquery(name="user_referral_count") + ) + + +def _user_subscription_expiry_sq(): + return ( + select( + Subscription.user_id.label("user_id"), + sa_func.max(Subscription.end_date).label("subscription_expires_at"), + ) + .group_by(Subscription.user_id) + .subquery(name="user_subscription_expiry") + ) + + +async def _bulk_user_payment_summaries( + session: AsyncSession, + user_ids: List[int], +) -> Dict[int, Dict[str, Any]]: + if not user_ids: + return {} + + stmt = ( + select( + Payment.user_id, + sa_func.coalesce(sa_func.sum(Payment.amount), 0.0), + sa_func.count(Payment.payment_id), + sa_func.max(Payment.currency), + ) + .where(Payment.user_id.in_(user_ids), Payment.status == "succeeded") + .group_by(Payment.user_id) + ) + rows = (await session.execute(stmt)).all() + return { + int(user_id): { + "total_amount": float(total_amount or 0), + "count": int(payments_count or 0), + "currency": currency, + } + for user_id, total_amount, payments_count, currency in rows + } + + +async def _bulk_user_referral_counts( + session: AsyncSession, + user_ids: List[int], +) -> Dict[int, int]: + if not user_ids: + return {} + + referred_user = aliased(User) + stmt = ( + select(referred_user.referred_by_id, sa_func.count(referred_user.user_id)) + .where(referred_user.referred_by_id.in_(user_ids)) + .group_by(referred_user.referred_by_id) + ) + rows = (await session.execute(stmt)).all() + return {int(user_id): int(count or 0) for user_id, count in rows} + + async def _filter_and_sort_users( session: AsyncSession, *, @@ -393,6 +485,13 @@ async def _filter_and_sort_users( ratio_expr = None plim_expr = None pu_expr = None + payment_summary_sq = None + payment_total_expr = None + payment_count_expr = None + referral_count_sq = None + referral_count_expr = None + subscription_expiry_sq = None + subscription_expires_expr = None if needs_premium_sq: sq = _ranked_active_subscriptions_sq(now) @@ -413,6 +512,42 @@ async def _filter_and_sort_users( else_=cast(pu_expr, Float) / cast(plim_expr, Float), ) + if sort_key in { + "payments_total_asc", + "payments_total_desc", + "payments_count_asc", + "payments_count_desc", + }: + payment_summary_sq = _user_payment_summary_sq() + stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id) + count_stmt = count_stmt.outerjoin( + payment_summary_sq, + User.user_id == payment_summary_sq.c.user_id, + ) + payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0) + payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0) + + if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}: + referral_count_sq = _user_referral_count_sq() + stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id) + count_stmt = count_stmt.outerjoin( + referral_count_sq, + User.user_id == referral_count_sq.c.user_id, + ) + referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0) + + if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}: + subscription_expiry_sq = _user_subscription_expiry_sq() + stmt = stmt.outerjoin( + subscription_expiry_sq, + User.user_id == subscription_expiry_sq.c.user_id, + ) + count_stmt = count_stmt.outerjoin( + subscription_expiry_sq, + User.user_id == subscription_expiry_sq.c.user_id, + ) + subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at + search_cond = _user_search_condition(query) if search_cond is not None: stmt = stmt.where(search_cond) @@ -511,6 +646,22 @@ async def _filter_and_sort_users( stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc()) elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc": stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc()) + elif payment_total_expr is not None and sort_key == "payments_total_asc": + stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc()) + elif payment_total_expr is not None and sort_key == "payments_total_desc": + stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc()) + elif payment_count_expr is not None and sort_key == "payments_count_asc": + stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc()) + elif payment_count_expr is not None and sort_key == "payments_count_desc": + stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc()) + elif referral_count_expr is not None and sort_key == "invited_users_count_asc": + stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc()) + elif referral_count_expr is not None and sort_key == "invited_users_count_desc": + stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc()) + elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc": + stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc()) + elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc": + stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc()) else: order = sort_map.get(sort_key, sort_map["registered_desc"]) if isinstance(order, tuple): diff --git a/tests/test_admin_users_list_metrics.py b/tests/test_admin_users_list_metrics.py new file mode 100644 index 0000000..52b121d --- /dev/null +++ b/tests/test_admin_users_list_metrics.py @@ -0,0 +1,116 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from sqlalchemy.dialects import postgresql + +from bot.app.web.admin_api_impl import users as users_module + + +class FakeResult: + def __init__(self, rows=None, scalar_value=0): + self._rows = rows or [] + self._scalar_value = scalar_value + + def all(self): + return self._rows + + def scalars(self): + return self + + def scalar_one(self): + return self._scalar_value + + +def _compile_sql(stmt) -> str: + return str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).lower() + + +class AdminUsersListMetricsTests(unittest.IsolatedAsyncioTestCase): + async def test_bulk_user_payment_summaries_returns_succeeded_totals(self): + session = SimpleNamespace( + execute=AsyncMock(return_value=FakeResult([(101, 1234.5, 3, "RUB")])) + ) + + result = await users_module._bulk_user_payment_summaries(session, [101]) + + self.assertEqual( + result, + { + 101: { + "total_amount": 1234.5, + "count": 3, + "currency": "RUB", + } + }, + ) + sql = _compile_sql(session.execute.await_args.args[0]) + self.assertIn("payments.status = 'succeeded'", sql) + self.assertIn("sum(payments.amount)", sql) + self.assertIn("count(payments.payment_id)", sql) + + async def test_bulk_user_referral_counts_groups_invited_users(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult([(101, 7)]))) + + result = await users_module._bulk_user_referral_counts(session, [101]) + + self.assertEqual(result, {101: 7}) + sql = _compile_sql(session.execute.await_args.args[0]) + self.assertIn("referred_by_id", sql) + self.assertIn("group by", sql) + + async def test_filter_sort_users_supports_payment_total_sort(self): + session = SimpleNamespace( + execute=AsyncMock(side_effect=[FakeResult([]), FakeResult(scalar_value=0)]) + ) + + await users_module._filter_and_sort_users( + session, + query="", + filter_value="all", + panel_status="all", + premium_traffic="all", + sort_value="payments_total_desc", + page=0, + page_size=25, + ) + + sql = _compile_sql(session.execute.await_args_list[0].args[0]) + self.assertIn("user_payment_summary", sql) + self.assertIn("payments_total_amount", sql) + self.assertIn("order by coalesce", sql) + self.assertIn("desc", sql) + + async def test_filter_sort_users_supports_referral_and_subscription_sorts(self): + for sort_value, expected_alias in ( + ("invited_users_count_desc", "user_referral_count"), + ("subscription_expires_at_asc", "user_subscription_expiry"), + ): + with self.subTest(sort_value=sort_value): + session = SimpleNamespace( + execute=AsyncMock(side_effect=[FakeResult([]), FakeResult(scalar_value=0)]) + ) + + await users_module._filter_and_sort_users( + session, + query="", + filter_value="all", + panel_status="all", + premium_traffic="all", + sort_value=sort_value, + page=0, + page_size=25, + ) + + sql = _compile_sql(session.execute.await_args_list[0].args[0]) + self.assertIn(expected_alias, sql) + self.assertIn("order by", sql) + + +if __name__ == "__main__": + unittest.main() From dcdddeb1c5eed81a3cd16e27730b4edd6018f0cb Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 15:53:03 +0300 Subject: [PATCH 3/4] feat: add sortable admin user columns --- frontend/src/admin/AdminPanel.svelte | 1 + .../src/admin/sections/UsersSection.svelte | 304 +++++++++++++++--- frontend/src/lib/admin/stores/usersStore.js | 4 +- frontend/src/lib/components/ui/icons.js | 3 + frontend/src/lib/webapp/mockApi.js | 159 ++++++--- locales/en.json | 7 + locales/ru.json | 7 + 7 files changed, 398 insertions(+), 87 deletions(-) diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index ab381fe..bd47e25 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -785,6 +785,7 @@ import { Input } from "$components/ui/index.js"; + import { + ArrowDown, + ArrowUp, + ChevronsUpDown, + DollarSign, + UsersRound, + } from "$components/ui/icons.js"; import { Label } from "$components/ui/primitives.js"; import { AdminBadge, @@ -15,6 +22,7 @@ export let at = (key) => key; export let fmtDateShort = (value) => value; + export let fmtMoney = (value) => value; export let panelStatusBadge = () => ({}); export let resolvedAvatarUrl = () => ""; export let userDisplayName = () => ""; @@ -49,16 +57,31 @@ { value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") }, ]; - const USERS_SORT_OPTIONS = [ - { value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") }, - { value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") }, - { value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") }, - { value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") }, - { value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") }, - { value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") }, - { value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") }, - { value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") }, - ]; + const SORT_COLUMNS = { + user: { asc: "name_asc", desc: "name_desc", defaultDirection: "asc" }, + premium: { asc: "premium_ratio_asc", desc: "premium_ratio_desc", defaultDirection: "desc" }, + paymentsTotal: { + asc: "payments_total_asc", + desc: "payments_total_desc", + defaultDirection: "desc", + }, + paymentsCount: { + asc: "payments_count_asc", + desc: "payments_count_desc", + defaultDirection: "desc", + }, + invited: { + asc: "invited_users_count_asc", + desc: "invited_users_count_desc", + defaultDirection: "desc", + }, + subscriptionExpires: { + asc: "subscription_expires_at_asc", + desc: "subscription_expires_at_desc", + defaultDirection: "asc", + }, + registration: { asc: "registered_asc", desc: "registered_desc", defaultDirection: "desc" }, + }; const USERS_PANEL_STATUS_OPTIONS = [ { value: "all", label: at("panel_status_all", {}, "Все статусы") }, @@ -94,12 +117,77 @@ return trafficOfLabel(pt.used_bytes, pt.limit_bytes); } - $: userTableHeaders = [ - at("user", {}, "Пользователь"), - at("premium_traffic_filter_label", {}, "Премиум трафик"), - at("status", {}, "Статус"), - at("users_col_registration", {}, "Регистрация"), - ]; + function userTableColumns() { + return [ + { key: "user", label: at("user", {}, "Пользователь"), sort: SORT_COLUMNS.user }, + { + key: "premium", + label: at("premium_traffic_filter_label", {}, "Премиум трафик"), + sort: SORT_COLUMNS.premium, + }, + { + key: "paymentsTotal", + label: at("users_col_payments_total", {}, "Сумма платежей"), + sort: SORT_COLUMNS.paymentsTotal, + }, + { + key: "paymentsCount", + label: at("users_col_payments_count", {}, "Платежи"), + sort: SORT_COLUMNS.paymentsCount, + }, + { + key: "invited", + label: at("users_col_invited", {}, "Приглашенные"), + sort: SORT_COLUMNS.invited, + }, + { key: "status", label: at("status", {}, "Статус") }, + { + key: "subscriptionExpires", + label: at("users_col_subscription_expires", {}, "Истекает"), + sort: SORT_COLUMNS.subscriptionExpires, + }, + { + key: "registration", + label: at("users_col_registration", {}, "Регистрация"), + sort: SORT_COLUMNS.registration, + }, + ]; + } + + function sortState(column) { + if (!column) return "none"; + if (usersSort === column.asc) return "ascending"; + if (usersSort === column.desc) return "descending"; + return "none"; + } + + function nextSortValue(column) { + const state = sortState(column); + const defaultValue = column[column.defaultDirection] || column.asc; + if (state === "none") return defaultValue; + if (usersSort === defaultValue) { + return column.defaultDirection === "asc" ? column.desc : column.asc; + } + return ""; + } + + function toggleUsersSort(column) { + usersStore.updateState({ usersSort: nextSortValue(column), usersPage: 0 }); + usersStore.loadUsers(); + } + + function sortTitle(column) { + const state = sortState(column); + if (state === "ascending") return at("sort_ascending", {}, "По возрастанию"); + if (state === "descending") return at("sort_descending", {}, "По убыванию"); + return at("sort_off", {}, "Без сортировки"); + } + + function rowPaymentsTotal(user) { + return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB"); + } + + $: userTableHeaders = userTableColumns().map((column) => column.label); onMount(() => { usersStore.loadUsers(); @@ -171,20 +259,6 @@ /> - - {at("sort", {}, "Сортировка")} - { - usersStore.updateState({ usersSort: value, usersPage: 0 }); - usersStore.loadUsers(); - }} - /> - -
{at("total", {}, "Всего")} {usersTotal} @@ -192,12 +266,12 @@
-
+
{#if usersLoading} {:else if !users.length} - {at("user", {}, "Пользователь")} - {at("premium_traffic_filter_label", {}, "Премиум трафик")} - {at("status", {}, "Статус")} - {at("users_col_registration", {}, "Регистрация")} + {#each userTableColumns() as column (column.key)} + + {#if column.sort} + + {:else} + {column.label} + {/if} + + {/each} @@ -264,9 +363,41 @@ > {/if} + + + {rowPaymentsTotal(user)} + + + + + + {user.payments_count ?? 0} + + + + + + {user.invited_users_count ?? 0} + + {badge.label} + + {fmtDateShort(user.subscription_expires_at || user.panel_status_expired_at)} + diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 082081b..8c8b1e6 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -13,7 +13,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { usersFilter: "all", usersPanelStatus: "all", usersPremiumTraffic: "all", - usersSort: "registered_desc", + usersSort: "", usersLoading: false, openedUser: null, @@ -103,7 +103,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") { params.set("premium_traffic", s.usersPremiumTraffic); } - if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort); + if (s.usersSort) params.set("sort", s.usersSort); const data = await api(`/admin/users?${params.toString()}`); if (data?.ok) { state.update((st) => ({ diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js index 3162fe9..605da1a 100644 --- a/frontend/src/lib/components/ui/icons.js +++ b/frontend/src/lib/components/ui/icons.js @@ -1,7 +1,9 @@ export { Activity, ArrowLeft, + ArrowDown, ArrowRight, + ArrowUp, Bitcoin, CalendarDays, Check, @@ -19,6 +21,7 @@ export { CreditCard, Crown, Database, + DollarSign, Download, ExternalLink, Eye, diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 8f0c816..a2a279f 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -401,6 +401,46 @@ function userName(user) { ); } +function demoUserSeed(user) { + return Math.abs(Number(user?.user_id || user?.telegram_id || 0)) || 1; +} + +function demoFutureIso(user, offsetDays = 30) { + const seed = demoUserSeed(user); + const base = Date.parse(user?.registration_date || "") || Date.UTC(2026, 0, 1); + return new Date(base + (offsetDays + (seed % 180)) * 86400000).toISOString(); +} + +function withDemoAdminUserMetrics(user) { + const seed = demoUserSeed(user); + const paymentsCount = + user.payments_count ?? (user.panel_status === "bot_only" ? 0 : Math.max(1, seed % 9)); + const paymentsTotal = user.payments_total_amount ?? paymentsCount * (290 + (seed % 11) * 75); + const invitedCount = user.invited_users_count ?? (seed % 5 === 0 ? seed % 8 : seed % 3); + const subscriptionExpiresAt = + user.subscription_expires_at ?? + user.panel_status_expired_at ?? + (user.panel_status === "active" ? demoFutureIso(user, 45) : null); + + return { + ...user, + payments_total_amount: paymentsTotal, + payments_count: paymentsCount, + payments_currency: user.payments_currency || "RUB", + invited_users_count: invitedCount, + subscription_expires_at: subscriptionExpiresAt, + }; +} + +function compareNullableDate(a, b, direction = "asc") { + const at = stringDate(a); + const bt = stringDate(b); + if (!at && !bt) return 0; + if (!at) return 1; + if (!bt) return -1; + return direction === "desc" ? bt - at : at - bt; +} + function withDemoAvatars(users, size = 96) { return (users || []).map((user) => withDemoAvatar(user, size)); } @@ -436,7 +476,7 @@ function withDemoAvatarTickets(tickets, size = 96) { } function filterDemoUsers(params) { - let out = [...(DEMO_DATASET.adminUsers || [])]; + let out = (DEMO_DATASET.adminUsers || []).map(withDemoAdminUserMetrics); const q = (params.get("q") || params.get("search") || "").trim().toLowerCase(); if (q) { out = out.filter((user) => @@ -483,6 +523,22 @@ function filterDemoUsers(params) { return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1); if (sort === "premium_ratio_desc") return Number(b.premium_traffic?.percent ?? -1) - Number(a.premium_traffic?.percent ?? -1); + if (sort === "payments_total_asc") + return Number(a.payments_total_amount || 0) - Number(b.payments_total_amount || 0); + if (sort === "payments_total_desc") + return Number(b.payments_total_amount || 0) - Number(a.payments_total_amount || 0); + if (sort === "payments_count_asc") + return Number(a.payments_count || 0) - Number(b.payments_count || 0); + if (sort === "payments_count_desc") + return Number(b.payments_count || 0) - Number(a.payments_count || 0); + if (sort === "invited_users_count_asc") + return Number(a.invited_users_count || 0) - Number(b.invited_users_count || 0); + if (sort === "invited_users_count_desc") + return Number(b.invited_users_count || 0) - Number(a.invited_users_count || 0); + if (sort === "subscription_expires_at_asc") + return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "asc"); + if (sort === "subscription_expires_at_desc") + return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "desc"); return stringDate(b.registration_date) - stringDate(a.registration_date); }); @@ -976,56 +1032,61 @@ export async function mockApi(path, options = {}, context = {}) { normalizeLangCode, }); if (demoResponse !== undefined) return demoResponse; - const adminUsers = withDemoAvatars([ - { - user_id: 100200300, - telegram_id: 100200300, - username: "anna_ops", - first_name: "Анна", - last_name: "Смирнова", - email: "anna@example.com", - telegram_photo_url: "", - registration_date: "2026-04-24T10:20:00Z", - is_banned: false, - premium_traffic: { - state: "good", - unlimited: false, - used_bytes: 4 * 1073741824, - limit_bytes: 25 * 1073741824, - percent: 16, + const adminUsers = withDemoAvatars( + [ + { + user_id: 100200300, + telegram_id: 100200300, + username: "anna_ops", + first_name: "Анна", + last_name: "Смирнова", + email: "anna@example.com", + telegram_photo_url: "", + registration_date: "2026-04-24T10:20:00Z", + is_banned: false, + premium_traffic: { + state: "good", + unlimited: false, + used_bytes: 4 * 1073741824, + limit_bytes: 25 * 1073741824, + percent: 16, + }, + panel_status: "active", }, - }, - { - user_id: 100200301, - telegram_id: 87543123, - username: "client_pro", - first_name: "Максим", - last_name: "Котов", - email: "", - telegram_photo_url: "", - registration_date: "2026-04-26T08:15:00Z", - is_banned: false, - premium_traffic: { - state: "warn", - unlimited: false, - used_bytes: 22 * 1073741824, - limit_bytes: 25 * 1073741824, - percent: 88, + { + user_id: 100200301, + telegram_id: 87543123, + username: "client_pro", + first_name: "Максим", + last_name: "Котов", + email: "", + telegram_photo_url: "", + registration_date: "2026-04-26T08:15:00Z", + is_banned: false, + premium_traffic: { + state: "warn", + unlimited: false, + used_bytes: 22 * 1073741824, + limit_bytes: 25 * 1073741824, + percent: 88, + }, + panel_status: "active", }, - }, - { - user_id: 100200302, - telegram_id: 88440011, - username: "", - first_name: "Daria", - last_name: "", - email: "daria@example.com", - telegram_photo_url: "", - registration_date: "2026-04-29T16:45:00Z", - is_banned: true, - premium_traffic: { state: "none" }, - }, - ]); + { + user_id: 100200302, + telegram_id: 88440011, + username: "", + first_name: "Daria", + last_name: "", + email: "daria@example.com", + telegram_photo_url: "", + registration_date: "2026-04-29T16:45:00Z", + is_banned: true, + premium_traffic: { state: "none" }, + panel_status: "bot_only", + }, + ].map(withDemoAdminUserMetrics) + ); const supportTickets = [ { ticket_id: 42, diff --git a/locales/en.json b/locales/en.json index b37f5f3..3c1d8eb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1024,6 +1024,9 @@ "admin_sort_id_desc": "ID ↓", "admin_sort_premium_ratio_asc": "Premium usage % ↑", "admin_sort_premium_ratio_desc": "Premium usage % ↓", + "admin_sort_ascending": "Sorted ascending", + "admin_sort_descending": "Sorted descending", + "admin_sort_off": "Not sorted", "admin_premium_traffic_filter_label": "Premium traffic", "admin_premium_traffic_filter_all": "All", "admin_premium_traffic_filter_none": "No tariff limit", @@ -1043,6 +1046,10 @@ "admin_sort": "Sort", "admin_total": "Total", "admin_users_empty": "No users found", + "admin_users_col_payments_total": "Paid total", + "admin_users_col_payments_count": "Payments", + "admin_users_col_invited": "Invited", + "admin_users_col_subscription_expires": "Expires", "admin_users_col_registration": "Registered", "admin_page": "Page", "admin_page_short": "Page", diff --git a/locales/ru.json b/locales/ru.json index cdc1f9e..a32bd98 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1024,6 +1024,9 @@ "admin_sort_id_desc": "ID ↓", "admin_sort_premium_ratio_asc": "Премиум % ↑", "admin_sort_premium_ratio_desc": "Премиум % ↓", + "admin_sort_ascending": "Сортировка по возрастанию", + "admin_sort_descending": "Сортировка по убыванию", + "admin_sort_off": "Без сортировки", "admin_premium_traffic_filter_label": "Премиум трафик", "admin_premium_traffic_filter_all": "Все", "admin_premium_traffic_filter_none": "Без лимита в тарифе", @@ -1043,6 +1046,10 @@ "admin_sort": "Сортировка", "admin_total": "Всего", "admin_users_empty": "Никого не найдено", + "admin_users_col_payments_total": "Сумма платежей", + "admin_users_col_payments_count": "Платежи", + "admin_users_col_invited": "Приглашенные", + "admin_users_col_subscription_expires": "Истекает", "admin_users_col_registration": "Регистрация", "admin_page": "Страница", "admin_page_short": "Стр.", From 8366a7357557e09ee7e67be51ccf6cdbf85080a0 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 15:58:28 +0300 Subject: [PATCH 4/4] style: format admin users api --- backend/bot/app/web/admin_api_impl/users.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 98703ff..f578cbd 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -128,9 +128,7 @@ async def _load_admin_users_list_payload_uncached( active_subs = await _bulk_active_subscriptions_for_users( session, [u.user_id for u in users] ) - payment_summaries = await _bulk_user_payment_summaries( - session, [u.user_id for u in users] - ) + payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users]) referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users]) serialized = []