feat: expose admin user list metrics
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user