Merge pull request #20 from 3252a8/feature/users-details
Enhance admin users and subscription workflows
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -127,12 +128,15 @@ 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"] = (
|
||||
@@ -141,6 +145,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 {
|
||||
@@ -364,6 +373,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,
|
||||
*,
|
||||
@@ -392,6 +483,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)
|
||||
@@ -412,6 +510,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)
|
||||
@@ -510,6 +644,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):
|
||||
@@ -538,9 +688,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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -785,6 +785,7 @@
|
||||
<UsersSection
|
||||
{at}
|
||||
{fmtDateShort}
|
||||
{fmtMoney}
|
||||
{panelStatusBadge}
|
||||
{resolvedAvatarUrl}
|
||||
{userDisplayName}
|
||||
|
||||
@@ -728,7 +728,11 @@
|
||||
</Card.Header>
|
||||
<Card.Footer class="admin-cn-card-footer--stack">
|
||||
<div class="admin-cn-card-footer-primary">
|
||||
{at("stats_trend_new_today", { count: users.active_today ?? 0 }, "")}
|
||||
{at(
|
||||
"stats_trend_expired_subscriptions",
|
||||
{ count: users.expired_subscription_users ?? 0 },
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
|
||||
</Card.Footer>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script>
|
||||
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 @@
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
||||
<AdminSelect
|
||||
value={usersSort}
|
||||
items={USERS_SORT_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("sort", {}, "Сортировка")}
|
||||
onValueChange={(value) => {
|
||||
usersStore.updateState({ usersSort: value, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<div class="admin-toolbar-summary">
|
||||
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
||||
<strong>{usersTotal}</strong>
|
||||
@@ -192,12 +266,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap admin-users-table-wrap">
|
||||
<div class="admin-users-table-wrap">
|
||||
{#if usersLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={userTableHeaders}
|
||||
rows={USERS_PAGE_SIZE}
|
||||
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
|
||||
widths={["220px", "128px", "112px", "78px", "88px", "96px", "112px", "112px"]}
|
||||
/>
|
||||
{:else if !users.length}
|
||||
<AdminEmptyState tone="card"
|
||||
@@ -208,10 +282,35 @@
|
||||
<AdminTable class="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("user", {}, "Пользователь")}</th>
|
||||
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
||||
<th>{at("status", {}, "Статус")}</th>
|
||||
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
||||
{#each userTableColumns() as column (column.key)}
|
||||
<th aria-sort={column.sort ? sortState(column.sort) : undefined}>
|
||||
{#if column.sort}
|
||||
<button
|
||||
type="button"
|
||||
class="admin-sort-header"
|
||||
title={sortTitle(column.sort)}
|
||||
on:click={() => toggleUsersSort(column.sort)}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span
|
||||
class="admin-sort-state"
|
||||
data-state={sortState(column.sort)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if sortState(column.sort) === "ascending"}
|
||||
<ArrowUp size={13} />
|
||||
{:else if sortState(column.sort) === "descending"}
|
||||
<ArrowDown size={13} />
|
||||
{:else}
|
||||
<ChevronsUpDown size={13} />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
{column.label}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -264,9 +363,41 @@
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-money"
|
||||
data-label={at("users_col_payments_total", {}, "Сумма платежей")}
|
||||
>
|
||||
<AdminBadge variant="success" class="admin-user-money-badge">
|
||||
{rowPaymentsTotal(user)}
|
||||
</AdminBadge>
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-counter"
|
||||
data-label={at("users_col_payments_count", {}, "Платежи")}
|
||||
>
|
||||
<span class="admin-user-counter">
|
||||
<DollarSign size={12} />
|
||||
<span>{user.payments_count ?? 0}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-counter"
|
||||
data-label={at("users_col_invited", {}, "Приглашенные")}
|
||||
>
|
||||
<span class="admin-user-counter">
|
||||
<UsersRound size={13} />
|
||||
<span>{user.invited_users_count ?? 0}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td data-label={at("status", {}, "Статус")}>
|
||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-date admin-cell-mono"
|
||||
data-label={at("users_col_subscription_expires", {}, "Истекает")}
|
||||
>
|
||||
{fmtDateShort(user.subscription_expires_at || user.panel_status_expired_at)}
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-date admin-cell-mono"
|
||||
data-label={at("users_col_registration", {}, "Регистрация")}
|
||||
@@ -297,6 +428,57 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
:global(.admin-toolbar-users .admin-toolbar-controls) {
|
||||
grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto);
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-table-wrap) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table) {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.admin-sort-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 100%;
|
||||
margin: -4px -6px;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-sort-header:hover,
|
||||
.admin-sort-header:focus-visible {
|
||||
color: var(--admin-text);
|
||||
background: color-mix(in srgb, var(--admin-muted) 10%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-sort-header:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--admin-ring);
|
||||
}
|
||||
|
||||
.admin-sort-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--admin-dim);
|
||||
}
|
||||
|
||||
.admin-sort-state[data-state="ascending"],
|
||||
.admin-sort-state[data-state="descending"] {
|
||||
color: color-mix(in srgb, var(--accent) 72%, var(--admin-muted));
|
||||
}
|
||||
|
||||
.admin-users-cell-user-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -353,6 +535,30 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-users-cell-money,
|
||||
.admin-users-cell-counter {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-money :global(.admin-user-money-badge) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-counter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--admin-text);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-counter :global(svg) {
|
||||
color: var(--admin-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-users-cell-date {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
@@ -363,4 +569,30 @@
|
||||
outline: 2px solid var(--admin-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-users-table-wrap :global(.admin-users-table thead) {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody tr) {
|
||||
display: table-row;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody tr:last-child td) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody td) {
|
||||
display: table-cell;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody td::before) {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
@@ -1278,6 +1339,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,
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
@@ -1023,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",
|
||||
@@ -1042,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",
|
||||
@@ -1127,6 +1135,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 +1163,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)",
|
||||
|
||||
@@ -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": "Ошибка: сообщение для рассылки не найдено.",
|
||||
@@ -1023,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": "Без лимита в тарифе",
|
||||
@@ -1042,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": "Стр.",
|
||||
@@ -1127,6 +1135,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 +1163,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)",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user