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] 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()