From c2344824dc961d2194ad809e345821c80a711a96 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 00:36:05 +0300 Subject: [PATCH] feat: show referral relationships in admin user cards --- backend/bot/app/web/admin_api_impl/routes.py | 1 + backend/bot/app/web/admin_api_impl/users.py | 69 +++++- backend/bot/handlers/admin/user_management.py | 165 +++++++++++++- backend/db/dal/user_dal.py | 33 +++ .../src/admin/sections/UserDetailModal.svelte | 204 ++++++++++++++++++ frontend/src/lib/admin/stores/usersStore.js | 74 +++++++ frontend/src/lib/webapp/mockApi.js | 41 +++- locales/en.json | 18 ++ locales/ru.json | 18 ++ tests/test_user_dal.py | 50 +++++ tests/test_webapp_route_contract.py | 1 + 11 files changed, 662 insertions(+), 12 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 5b20c35..6b0a10c 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -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) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index b4ea1df..29ee2e2 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -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"]) diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index 786fbd2..2476e0e 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -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: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 8b506e4..cc2102e 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -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) diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index a424b25..f0ecc1d 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -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); + } +
  • + {at("user_label_invited_by", {}, "Пригласил")} + + {#if referralInviter} + {userDisplayName(referralInviter)} + ID {referralInviter.user_id} + {:else} + {at("user_invited_by_none", {}, "—")} + {/if} + + {#if referralInviter} + openRelatedUser(referralInviter)} + > + + + {/if} +
  • +
  • + {at("user_label_invited_users", {}, "Приглашённые")} + {referralInviteesTotal} + usersStore.openUserReferrals(0)} + > + + {at("user_invitees_open", {}, "Показать")} + +
  • {#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link} @@ -934,6 +987,101 @@ {/if}
    + +
    + {#if userReferralsLoading} + + {:else if !userReferrals.length} + + {at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")} + + {:else} + + + + + {at("user_col_user", {}, "Пользователь")} + ID + {at("user_label_registration", {}, "Регистрация")} + + + + + {#each userReferrals as invitee (invitee.user_id)} + + + + {userDisplayName(invitee)} + {userSecondaryName(invitee)} + + + {invitee.user_id} + + {fmtDateShort(invitee.registration_date)} + + + openRelatedUser(invitee)} + > + + + + + {/each} + + + + {/if} + + {#if userReferralsTotal > userReferralsPageSize} + usersStore.setUserReferralsPage(userReferralsPage - 1)} + onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)} + /> + {/if} +
    +
    + span { + grid-column: 1 / -1; + } + } :global(.admin-avatar-dialog) { display: grid; grid-template-rows: auto minmax(0, 1fr); diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 4e58ad7..082081b 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -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, }; } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 670e33f..058d813 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -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"}` }; } diff --git a/locales/en.json b/locales/en.json index 64be717..2346c55 100644 --- a/locales/en.json +++ b/locales/en.json @@ -440,6 +440,13 @@ "admin_user_referral_revenue_label": "💸 Referral Revenue:", "admin_user_invited_friends_label": "👥 Friends invited:", "admin_user_ref_purchased_label": "💳 Purchased subscription:", + "admin_user_invited_by_label": "🎁 Invited by:", + "admin_user_invitees_button": "👥 Invited users", + "admin_user_invitees_message_title": "👥 Users invited by {user}\n\nTotal: {total}. 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": "🔗 Links", "admin_user_subscription_url_label": "📡 Subscription:", "admin_user_ref_bot_link_label": "🤖 Referral link (bot):", @@ -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.", diff --git a/locales/ru.json b/locales/ru.json index 650f6e9..a8fc836 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -440,6 +440,13 @@ "admin_user_referral_revenue_label": "💸 Доход по рефералам:", "admin_user_invited_friends_label": "👥 Приглашено друзей:", "admin_user_ref_purchased_label": "💳 Купили подписку:", + "admin_user_invited_by_label": "🎁 Пригласил:", + "admin_user_invitees_button": "👥 Приглашённые", + "admin_user_invitees_message_title": "👥 Пользователи, приглашённые {user}\n\nВсего: {total}. Страница {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": "🔗 Ссылки", "admin_user_subscription_url_label": "📡 Подписка:", "admin_user_ref_bot_link_label": "🤖 Реф. ссылка (бот):", @@ -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-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.", diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index a0358ec..873e587 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -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") diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 3384482..e201b8a 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -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",