feat: show referral relationships in admin user cards
This commit is contained in:
@@ -9,6 +9,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
|
||||
|
||||
@@ -255,6 +255,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
|
||||
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
|
||||
|
||||
|
||||
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
|
||||
payload = _serialize_user(user)
|
||||
user_id = int(user.user_id)
|
||||
payload["avatar_url"] = (
|
||||
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
|
||||
if user_id in avatar_keys
|
||||
else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
||||
|
||||
@@ -587,7 +598,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
avatar_user_ids = [target_id]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
# Referral links — both the bot deep-link and the webapp deep-link.
|
||||
referral_code: Optional[str] = None
|
||||
@@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
exc_panel,
|
||||
)
|
||||
|
||||
serialized_user = _serialize_user(user)
|
||||
serialized_user["avatar_url"] = (
|
||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
||||
if target_id in avatar_keys
|
||||
else None
|
||||
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||
serialized_inviter = (
|
||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||
)
|
||||
|
||||
return _ok(
|
||||
@@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
"webapp_link": referral_webapp_link,
|
||||
"inviter": serialized_inviter,
|
||||
"invitees_total": int(invitees_total or 0),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_referrals_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
page = max(0, int(request.query.get("page", 0) or 0))
|
||||
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
user = await user_dal.get_user_by_id(session, target_id)
|
||||
if not user:
|
||||
return _error(404, "not_found", "User not found")
|
||||
|
||||
inviter = await user_dal.get_referrer_for_user(session, user)
|
||||
invitees_total = await user_dal.count_users_referred_by(session, target_id)
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
target_id,
|
||||
limit=page_size,
|
||||
offset=page * page_size,
|
||||
)
|
||||
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
|
||||
if inviter is not None:
|
||||
avatar_user_ids.append(int(inviter.user_id))
|
||||
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
|
||||
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
|
||||
if inviter is not None
|
||||
else None,
|
||||
"invitees": [
|
||||
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
|
||||
],
|
||||
"total": int(invitees_total or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
|
||||
@@ -88,6 +88,31 @@ async def _find_user_by_admin_input(
|
||||
return None
|
||||
|
||||
|
||||
def _admin_user_reference_label(
|
||||
user: Optional[User], fallback_user_id: Optional[int] = None
|
||||
) -> str:
|
||||
if user is None:
|
||||
return f"ID {fallback_user_id}" if fallback_user_id is not None else "N/A"
|
||||
|
||||
first_name = sanitize_display_name(user.first_name) if user.first_name else ""
|
||||
last_name = sanitize_display_name(user.last_name) if user.last_name else ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
if full_name:
|
||||
label = full_name
|
||||
elif user.username:
|
||||
label = username_for_display(user.username, with_at=True)
|
||||
elif user.email:
|
||||
label = user.email
|
||||
else:
|
||||
label = f"ID {user.user_id}"
|
||||
return f"{label} · ID {user.user_id}"
|
||||
|
||||
|
||||
def _admin_user_button_label(user: User) -> str:
|
||||
label = _admin_user_reference_label(user)
|
||||
return label[:64]
|
||||
|
||||
|
||||
async def users_list_handler(
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
@@ -195,7 +220,13 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 3b: Premium override + traffic grant
|
||||
# Row 3b: Referral details
|
||||
builder.button(
|
||||
text=_(key="admin_user_invitees_button"),
|
||||
callback_data=f"user_action:invitees:{user_id}:0",
|
||||
)
|
||||
|
||||
# Row 4: Premium override + traffic grant
|
||||
builder.button(
|
||||
text=_(key="admin_user_premium_override_button"),
|
||||
callback_data=f"user_action:premium_override:{user_id}",
|
||||
@@ -229,9 +260,9 @@ def get_user_card_keyboard(
|
||||
|
||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||
if quick_links_count == 0:
|
||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -314,7 +345,11 @@ async def format_user_card(
|
||||
|
||||
# Referral info
|
||||
if user.referred_by_id:
|
||||
card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}")
|
||||
referrer = await user_dal.get_referrer_for_user(session, user)
|
||||
card_parts.append(
|
||||
f"{_('admin_user_invited_by_label')} "
|
||||
f"{hcode(_admin_user_reference_label(referrer, user.referred_by_id))}"
|
||||
)
|
||||
|
||||
# Panel info
|
||||
if user.panel_user_uuid:
|
||||
@@ -619,6 +654,12 @@ async def user_action_handler(
|
||||
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||
elif action == "view_logs":
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "invitees":
|
||||
try:
|
||||
page = max(0, int(parts[3])) if len(parts) > 3 else 0
|
||||
except (TypeError, ValueError):
|
||||
page = 0
|
||||
await handle_view_user_invitees(callback, user, session, i18n, current_lang, page=page)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
||||
@@ -1056,6 +1097,120 @@ async def handle_view_user_logs(
|
||||
await callback.answer(_("admin_user_logs_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_view_user_invitees(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
page: int = 0,
|
||||
):
|
||||
"""Show users invited by the selected account."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
page_size = 10
|
||||
safe_page = max(0, int(page or 0))
|
||||
|
||||
try:
|
||||
total = await user_dal.count_users_referred_by(session, user.user_id)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
if safe_page >= total_pages:
|
||||
safe_page = total_pages - 1
|
||||
invitees = await user_dal.get_users_referred_by(
|
||||
session,
|
||||
user.user_id,
|
||||
limit=page_size,
|
||||
offset=safe_page * page_size,
|
||||
)
|
||||
|
||||
header = _(
|
||||
"admin_user_invitees_message_title",
|
||||
user=hcode(_admin_user_reference_label(user)),
|
||||
total=total,
|
||||
current=safe_page + 1,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
if total <= 0:
|
||||
invitees_text = f"{header}\n\n{_('admin_user_invitees_empty')}"
|
||||
else:
|
||||
lines = []
|
||||
for index, invitee in enumerate(invitees, start=safe_page * page_size + 1):
|
||||
registered = (
|
||||
invitee.registration_date.strftime("%Y-%m-%d")
|
||||
if invitee.registration_date
|
||||
else ""
|
||||
)
|
||||
suffix = (
|
||||
_("admin_user_invitee_registered_suffix", date=registered) if registered else ""
|
||||
)
|
||||
lines.append(
|
||||
_(
|
||||
"admin_user_invitee_item",
|
||||
index=index,
|
||||
user=hcode(_admin_user_reference_label(invitee)),
|
||||
suffix=suffix,
|
||||
)
|
||||
)
|
||||
invitees_text = "\n".join([header, "", *lines])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for invitee in invitees:
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_admin_user_button_label(invitee),
|
||||
callback_data=f"user_action:refresh:{invitee.user_id}",
|
||||
)
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if safe_page > 0:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page - 1}",
|
||||
)
|
||||
)
|
||||
if safe_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"user_action:invitees:{user.user_id}:{safe_page + 1}",
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
invitees_text, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error viewing invitees for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await callback.answer(_("admin_user_invitees_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_refresh_user_card(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1964,7 +2119,7 @@ async def user_card_from_list_handler(
|
||||
text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
quick_links_width = 2 if user.referred_by_id else 1
|
||||
keyboard.adjust(2, 2, 2, 2, quick_links_width, 1, 2, 1)
|
||||
keyboard.adjust(2, 2, 2, 1, 2, quick_links_width, 1, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
|
||||
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_referrer_for_user(session: AsyncSession, user: User) -> Optional[User]:
|
||||
referred_by_id = getattr(user, "referred_by_id", None)
|
||||
if referred_by_id is None:
|
||||
return None
|
||||
return await get_user_by_id(session, int(referred_by_id))
|
||||
|
||||
|
||||
async def get_users_referred_by(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[User]:
|
||||
safe_limit = max(1, min(500, int(limit or 50)))
|
||||
safe_offset = max(0, int(offset or 0))
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.registration_date.desc().nullslast(), User.user_id.desc())
|
||||
.offset(safe_offset)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_referred_by(session: AsyncSession, user_id: int) -> int:
|
||||
stmt = select(func.count(User.user_id)).where(User.referred_by_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip("@").lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
UsersRound,
|
||||
} from "$components/ui/icons.js";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
@@ -63,6 +64,12 @@
|
||||
userDeleteOpen,
|
||||
userBanConfirmOpen,
|
||||
userMessageConfirmOpen,
|
||||
userReferralsOpen,
|
||||
userReferralsLoading,
|
||||
userReferrals,
|
||||
userReferralsTotal,
|
||||
userReferralsPage,
|
||||
userReferralsPageSize,
|
||||
premiumUnlimitedDraft,
|
||||
userDetailTab,
|
||||
userLogs,
|
||||
@@ -75,8 +82,13 @@
|
||||
|
||||
$: userLogsHasMore =
|
||||
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
||||
$: userReferralsHasMore =
|
||||
Number(userReferralsTotal || 0) >
|
||||
(Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25);
|
||||
|
||||
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
|
||||
$: referralInviter = openedUserDetail?.referral?.inviter || null;
|
||||
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
|
||||
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||
$: openedUserTelegramProfileHint =
|
||||
@@ -119,6 +131,12 @@
|
||||
}
|
||||
openTelegramProfileLink(openedUserTelegramProfileLink);
|
||||
}
|
||||
|
||||
function openRelatedUser(user) {
|
||||
if (!user?.user_id) return;
|
||||
usersStore.closeUserReferrals();
|
||||
usersStore.openUser(user);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
@@ -225,6 +243,41 @@
|
||||
"—"}</strong
|
||||
>
|
||||
</li>
|
||||
<li class="admin-user-ref-row">
|
||||
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
|
||||
<strong class="admin-user-ref-value">
|
||||
{#if referralInviter}
|
||||
<span>{userDisplayName(referralInviter)}</span>
|
||||
<small>ID {referralInviter.user_id}</small>
|
||||
{:else}
|
||||
<span>{at("user_invited_by_none", {}, "—")}</span>
|
||||
{/if}
|
||||
</strong>
|
||||
{#if referralInviter}
|
||||
<AdminButton
|
||||
size="icon"
|
||||
variant="icon"
|
||||
title={at("user_open_related", {}, "Открыть карточку")}
|
||||
aria-label={at("user_open_related", {}, "Открыть карточку")}
|
||||
onclick={() => openRelatedUser(referralInviter)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</li>
|
||||
<li class="admin-user-ref-row">
|
||||
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
|
||||
<strong>{referralInviteesTotal}</strong>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={referralInviteesTotal <= 0}
|
||||
onclick={() => usersStore.openUserReferrals(0)}
|
||||
>
|
||||
<UsersRound size={14} />
|
||||
{at("user_invitees_open", {}, "Показать")}
|
||||
</AdminButton>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
||||
@@ -934,6 +987,101 @@
|
||||
{/if}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={userReferralsOpen}
|
||||
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
|
||||
description={openedUser
|
||||
? at(
|
||||
"user_invitees_description",
|
||||
{ name: userDisplayName(openedUser), count: userReferralsTotal },
|
||||
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
|
||||
)
|
||||
: ""}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={usersStore.closeUserReferrals}
|
||||
class="admin-dialog admin-user-referrals-dialog"
|
||||
>
|
||||
<div class="admin-user-referrals-body">
|
||||
{#if userReferralsLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={[
|
||||
at("user_col_user", {}, "Пользователь"),
|
||||
"ID",
|
||||
at("user_label_registration", {}, "Регистрация"),
|
||||
"",
|
||||
]}
|
||||
rows={5}
|
||||
widths={["42%", "18%", "26%", "14%"]}
|
||||
/>
|
||||
{:else if !userReferrals.length}
|
||||
<AdminEmptyState tone="card">
|
||||
<span class="admin-muted"
|
||||
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
|
||||
>
|
||||
</AdminEmptyState>
|
||||
{:else}
|
||||
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
|
||||
<AdminTable class="admin-user-referrals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("user_col_user", {}, "Пользователь")}</th>
|
||||
<th>ID</th>
|
||||
<th>{at("user_label_registration", {}, "Регистрация")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each userReferrals as invitee (invitee.user_id)}
|
||||
<tr>
|
||||
<td data-label={at("user_col_user", {}, "Пользователь")}>
|
||||
<span class="admin-referral-user-cell">
|
||||
<strong>{userDisplayName(invitee)}</strong>
|
||||
<small>{userSecondaryName(invitee)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
|
||||
<td data-label={at("user_label_registration", {}, "Регистрация")}>
|
||||
{fmtDateShort(invitee.registration_date)}
|
||||
</td>
|
||||
<td class="admin-referral-user-actions">
|
||||
<AdminButton
|
||||
size="icon"
|
||||
variant="icon"
|
||||
title={at("user_open_related", {}, "Открыть карточку")}
|
||||
aria-label={at("user_open_related", {}, "Открыть карточку")}
|
||||
onclick={() => openRelatedUser(invitee)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
|
||||
{#if userReferralsTotal > userReferralsPageSize}
|
||||
<AdminPagination
|
||||
meta={at(
|
||||
"pagination_meta",
|
||||
{
|
||||
current: userReferralsPage + 1,
|
||||
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
|
||||
},
|
||||
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
|
||||
)}
|
||||
prevLabel={at("prev_page", {}, "Назад")}
|
||||
nextLabel={at("next_page", {}, "Вперёд")}
|
||||
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
|
||||
nextDisabled={userReferralsLoading || !userReferralsHasMore}
|
||||
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
|
||||
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={avatarPreviewOpen}
|
||||
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
||||
@@ -1159,6 +1307,62 @@
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.admin-user-ref-row {
|
||||
grid-template-columns: 130px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
.admin-user-ref-row :global(.admin-btn) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.admin-user-ref-value {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-user-ref-value small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(.admin-user-referrals-dialog) {
|
||||
width: min(760px, calc(100vw - 28px));
|
||||
max-height: min(760px, calc(100dvh - 28px));
|
||||
}
|
||||
.admin-user-referrals-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
:global(.admin-user-referrals-table-wrap) {
|
||||
min-height: 120px;
|
||||
}
|
||||
.admin-referral-user-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-referral-user-cell strong {
|
||||
color: var(--admin-text);
|
||||
font-weight: 650;
|
||||
word-break: break-word;
|
||||
}
|
||||
.admin-referral-user-cell small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.admin-referral-user-actions {
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.admin-user-ref-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.admin-user-ref-row > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
:global(.admin-avatar-dialog) {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
||||
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
userDeleteOpen: false,
|
||||
userBanConfirmOpen: false,
|
||||
userMessageConfirmOpen: false,
|
||||
userReferralsOpen: false,
|
||||
userReferralsLoading: false,
|
||||
userReferrals: [],
|
||||
userReferralsTotal: 0,
|
||||
userReferralsPage: 0,
|
||||
userReferralsPageSize: USERS_PAGE_SIZE,
|
||||
userReferralsInviter: null,
|
||||
userDetailTab: "profile",
|
||||
premiumUnlimitedDraft: false,
|
||||
premiumBonusGbDraft: "",
|
||||
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
userExtendDays: 30,
|
||||
userDetailLoading: true,
|
||||
userDetailTab: "subscription",
|
||||
userReferralsOpen: false,
|
||||
userReferralsLoading: false,
|
||||
userReferrals: [],
|
||||
userReferralsTotal: 0,
|
||||
userReferralsPage: 0,
|
||||
userReferralsInviter: null,
|
||||
userLogs: [],
|
||||
userLogsTotal: 0,
|
||||
userLogsPage: 0,
|
||||
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
userDeleteOpen: false,
|
||||
userBanConfirmOpen: false,
|
||||
userMessageConfirmOpen: false,
|
||||
userReferralsOpen: false,
|
||||
userReferralsLoading: false,
|
||||
userReferrals: [],
|
||||
userReferralsTotal: 0,
|
||||
userReferralsPage: 0,
|
||||
userReferralsInviter: null,
|
||||
userLogs: [],
|
||||
userLogsTotal: 0,
|
||||
userLogsPage: 0,
|
||||
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
loadUserLogs(page);
|
||||
}
|
||||
|
||||
async function openUserReferrals(page = 0) {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
const userId = s.openedUser.user_id;
|
||||
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : 0;
|
||||
state.update((st) => ({
|
||||
...st,
|
||||
userReferralsOpen: true,
|
||||
userReferralsLoading: true,
|
||||
userReferralsPage: targetPage,
|
||||
}));
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(targetPage),
|
||||
page_size: String(s.userReferralsPageSize || USERS_PAGE_SIZE),
|
||||
});
|
||||
const data = await api(`/admin/users/${userId}/referrals?${params.toString()}`);
|
||||
if (data?.ok) {
|
||||
state.update((st) => {
|
||||
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
|
||||
return {
|
||||
...st,
|
||||
userReferrals: data.invitees || [],
|
||||
userReferralsTotal: Number(data.total || 0),
|
||||
userReferralsPage: Number(data.page || 0),
|
||||
userReferralsPageSize: Number(data.page_size || st.userReferralsPageSize),
|
||||
userReferralsInviter: data.inviter || null,
|
||||
};
|
||||
});
|
||||
} else if (data?.error) {
|
||||
onToast(data.error);
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userReferralsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserReferrals() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
userReferralsOpen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function setUserReferralsPage(page) {
|
||||
openUserReferrals(page);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||
if (!text) return;
|
||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||
@@ -564,5 +635,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
grantTraffic,
|
||||
loadUserLogs,
|
||||
setUserLogsPage,
|
||||
openUserReferrals,
|
||||
closeUserReferrals,
|
||||
setUserReferralsPage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,6 +405,32 @@ function withDemoAvatars(users, size = 96) {
|
||||
return (users || []).map((user) => withDemoAvatar(user, size));
|
||||
}
|
||||
|
||||
function demoAdminUserById(userId) {
|
||||
return (DEMO_DATASET.adminUsers || []).find((user) => Number(user.user_id) === Number(userId));
|
||||
}
|
||||
|
||||
function demoInviteesForUser(userId) {
|
||||
return (DEMO_DATASET.adminUsers || [])
|
||||
.filter((user) => Number(user.referred_by_id) === Number(userId))
|
||||
.sort((a, b) => stringDate(b.registration_date) - stringDate(a.registration_date));
|
||||
}
|
||||
|
||||
function withDemoReferralSummary(detail) {
|
||||
if (!detail || typeof detail !== "object") return detail;
|
||||
const decorated = withDemoAvatarDetail(detail);
|
||||
const user = decorated.user || {};
|
||||
const inviter = user.referred_by_id ? demoAdminUserById(user.referred_by_id) : null;
|
||||
const invitees = demoInviteesForUser(user.user_id);
|
||||
return {
|
||||
...decorated,
|
||||
referral: {
|
||||
...(decorated.referral || {}),
|
||||
inviter: inviter ? withDemoAvatar(inviter) : null,
|
||||
invitees_total: invitees.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withDemoAvatarTickets(tickets, size = 96) {
|
||||
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
|
||||
}
|
||||
@@ -610,8 +636,21 @@ function demoApiResponse(path, cleanPath, options, context) {
|
||||
const id = Number(parts[3]);
|
||||
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
|
||||
if (!detail) return { ok: false, error: "not_found" };
|
||||
const decoratedDetail = withDemoAvatarDetail(detail);
|
||||
const decoratedDetail = withDemoReferralSummary(detail);
|
||||
if (parts[4]) {
|
||||
if (parts[4] === "referrals") {
|
||||
const invitees = demoInviteesForUser(id);
|
||||
const page = paged(invitees, params, 25);
|
||||
return {
|
||||
ok: true,
|
||||
user: clone(decoratedDetail.user),
|
||||
inviter: clone(decoratedDetail.referral?.inviter || null),
|
||||
invitees: clone(withDemoAvatars(page.items)),
|
||||
total: page.total,
|
||||
page: page.page,
|
||||
page_size: page.pageSize,
|
||||
};
|
||||
}
|
||||
if (parts[4] === "telegram-profile-link") {
|
||||
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
|
||||
}
|
||||
|
||||
@@ -440,6 +440,13 @@
|
||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_invited_by_label": "🎁 <b>Invited by:</b>",
|
||||
"admin_user_invitees_button": "👥 Invited users",
|
||||
"admin_user_invitees_message_title": "👥 <b>Users invited by {user}</b>\n\nTotal: <b>{total}</b>. Page {current}/{total_pages}.",
|
||||
"admin_user_invitees_empty": "This user has not invited anyone yet.",
|
||||
"admin_user_invitee_item": "{index}. {user}{suffix}",
|
||||
"admin_user_invitee_registered_suffix": " · registered {date}",
|
||||
"admin_user_invitees_error": "❌ Failed to load invited users",
|
||||
"admin_user_links_section_title": "🔗 <b>Links</b>",
|
||||
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
|
||||
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
|
||||
@@ -1247,6 +1254,14 @@
|
||||
"admin_user_section_profile": "Profile",
|
||||
"admin_user_label_registration": "Registration",
|
||||
"admin_user_label_ref_code": "Referral Code",
|
||||
"admin_user_label_invited_by": "Invited by",
|
||||
"admin_user_label_invited_users": "Invited users",
|
||||
"admin_user_invited_by_none": "—",
|
||||
"admin_user_invitees_open": "Show",
|
||||
"admin_user_open_related": "Open user card",
|
||||
"admin_user_invitees_title": "Invited users",
|
||||
"admin_user_invitees_description": "{name} · {count}",
|
||||
"admin_user_col_user": "User",
|
||||
"admin_user_section_links": "Links",
|
||||
"admin_user_copy_tooltip": "Copy",
|
||||
"admin_user_sub_link_copied": "Subscription link copied",
|
||||
@@ -1373,6 +1388,9 @@
|
||||
"admin_ads_col_registrations": "Registrations",
|
||||
"admin_ads_col_conversions": "Conversions",
|
||||
"admin_no_data": "No data",
|
||||
"admin_pagination_meta": "{current}/{total}",
|
||||
"admin_prev_page": "Back",
|
||||
"admin_next_page": "Next",
|
||||
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
|
||||
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
|
||||
"admin_settings_legacy_tariffs_warning_body": "These fields are only for the old mode without a JSON catalog. They do not apply when tariffs are configured in the dedicated Tariffs section.",
|
||||
|
||||
@@ -440,6 +440,13 @@
|
||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_invited_by_label": "🎁 <b>Пригласил:</b>",
|
||||
"admin_user_invitees_button": "👥 Приглашённые",
|
||||
"admin_user_invitees_message_title": "👥 <b>Пользователи, приглашённые {user}</b>\n\nВсего: <b>{total}</b>. Страница {current}/{total_pages}.",
|
||||
"admin_user_invitees_empty": "Пользователь пока никого не пригласил.",
|
||||
"admin_user_invitee_item": "{index}. {user}{suffix}",
|
||||
"admin_user_invitee_registered_suffix": " · регистрация {date}",
|
||||
"admin_user_invitees_error": "❌ Ошибка загрузки приглашённых пользователей",
|
||||
"admin_user_links_section_title": "🔗 <b>Ссылки</b>",
|
||||
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
|
||||
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
|
||||
@@ -1247,6 +1254,14 @@
|
||||
"admin_user_section_profile": "Профиль",
|
||||
"admin_user_label_registration": "Регистрация",
|
||||
"admin_user_label_ref_code": "Реф. код",
|
||||
"admin_user_label_invited_by": "Пригласил",
|
||||
"admin_user_label_invited_users": "Приглашённые",
|
||||
"admin_user_invited_by_none": "—",
|
||||
"admin_user_invitees_open": "Показать",
|
||||
"admin_user_open_related": "Открыть карточку",
|
||||
"admin_user_invitees_title": "Приглашённые пользователи",
|
||||
"admin_user_invitees_description": "{name} · {count}",
|
||||
"admin_user_col_user": "Пользователь",
|
||||
"admin_user_section_links": "Ссылки",
|
||||
"admin_user_copy_tooltip": "Скопировать",
|
||||
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
|
||||
@@ -1373,6 +1388,9 @@
|
||||
"admin_ads_col_registrations": "Регистрации",
|
||||
"admin_ads_col_conversions": "Конверсии",
|
||||
"admin_no_data": "Нет данных",
|
||||
"admin_pagination_meta": "{current}/{total}",
|
||||
"admin_prev_page": "Назад",
|
||||
"admin_next_page": "Вперёд",
|
||||
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
||||
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
||||
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
|
||||
|
||||
@@ -17,6 +17,9 @@ class FakeResult:
|
||||
def scalar_one_or_none(self):
|
||||
return self._scalar_value
|
||||
|
||||
def scalar_one(self):
|
||||
return self._scalar_value
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
@@ -71,6 +74,53 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("TRIAL", sql)
|
||||
|
||||
|
||||
class UserDalReferralTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_get_users_referred_by_filters_with_pagination(self):
|
||||
invited = [SimpleNamespace(user_id=1001), SimpleNamespace(user_id=1002)]
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(invited)))
|
||||
|
||||
result = await user_dal.get_users_referred_by(session, 42, limit=2, offset=10)
|
||||
|
||||
self.assertEqual(result, invited)
|
||||
stmt = session.execute.await_args.args[0]
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
).upper()
|
||||
self.assertIn("REFERRED_BY_ID = 42", sql)
|
||||
self.assertIn("ORDER BY", sql)
|
||||
self.assertIn("LIMIT 2", sql)
|
||||
self.assertIn("OFFSET 10", sql)
|
||||
|
||||
async def test_count_users_referred_by_counts_matching_rows(self):
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(3)))
|
||||
|
||||
result = await user_dal.count_users_referred_by(session, 42)
|
||||
|
||||
self.assertEqual(result, 3)
|
||||
stmt = session.execute.await_args.args[0]
|
||||
sql = str(
|
||||
stmt.compile(
|
||||
dialect=postgresql.dialect(),
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
).upper()
|
||||
self.assertIn("COUNT", sql)
|
||||
self.assertIn("REFERRED_BY_ID = 42", sql)
|
||||
|
||||
async def test_get_referrer_for_user_uses_referred_by_id(self):
|
||||
referrer = SimpleNamespace(user_id=7)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(referrer)))
|
||||
|
||||
result = await user_dal.get_referrer_for_user(
|
||||
session, SimpleNamespace(user_id=42, referred_by_id=7)
|
||||
)
|
||||
|
||||
self.assertIs(result, referrer)
|
||||
|
||||
|
||||
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
||||
|
||||
@@ -156,6 +156,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/api/admin/stats"): "admin_stats_route",
|
||||
("GET", "/api/admin/users"): "admin_users_list_route",
|
||||
("GET", "/api/admin/users/{user_id}"): "admin_user_detail_route",
|
||||
("GET", "/api/admin/users/{user_id}/referrals"): "admin_user_referrals_route",
|
||||
("GET", "/api/admin/users/{user_id}/avatar"): "admin_user_avatar_route",
|
||||
("POST", "/api/admin/users/{user_id}/ban"): "admin_user_ban_route",
|
||||
("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",
|
||||
|
||||
Reference in New Issue
Block a user