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", 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+}", 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_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+}/ban", admin_user_ban_route)
|
||||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_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}
|
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:
|
async def admin_user_avatar_route(request: web.Request) -> web.Response:
|
||||||
"""Serve the cached Telegram avatar for any user (admin-only).
|
"""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()
|
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
|
||||||
log_count = await message_log_dal.count_user_message_logs(session, target_id)
|
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 links — both the bot deep-link and the webapp deep-link.
|
||||||
referral_code: Optional[str] = None
|
referral_code: Optional[str] = None
|
||||||
@@ -635,11 +651,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
exc_panel,
|
exc_panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
serialized_user = _serialize_user(user)
|
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
|
||||||
serialized_user["avatar_url"] = (
|
serialized_inviter = (
|
||||||
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
|
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||||
if target_id in avatar_keys
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
@@ -655,11 +669,54 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_bot_link,
|
"bot_link": referral_bot_link,
|
||||||
"webapp_link": referral_webapp_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:
|
async def admin_user_ban_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ async def _find_user_by_admin_input(
|
|||||||
return None
|
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(
|
async def users_list_handler(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
i18n_data: dict,
|
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}"
|
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(
|
builder.button(
|
||||||
text=_(key="admin_user_premium_override_button"),
|
text=_(key="admin_user_premium_override_button"),
|
||||||
callback_data=f"user_action:premium_override:{user_id}",
|
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)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 2, 1, 2)
|
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||||
else:
|
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
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +345,11 @@ async def format_user_card(
|
|||||||
|
|
||||||
# Referral info
|
# Referral info
|
||||||
if user.referred_by_id:
|
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
|
# Panel info
|
||||||
if user.panel_user_uuid:
|
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)
|
await handle_send_message_prompt(callback, state, user, i18n, current_lang)
|
||||||
elif action == "view_logs":
|
elif action == "view_logs":
|
||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
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":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(
|
await handle_refresh_user_card(
|
||||||
callback, user, subscription_service, session, settings, i18n, current_lang
|
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)
|
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(
|
async def handle_refresh_user_card(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
user: User,
|
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}"
|
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
|
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
|
# Format user card
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -95,6 +95,39 @@ async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
|||||||
return result.scalar_one_or_none()
|
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]:
|
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||||
clean_username = username.lstrip("@").lower()
|
clean_username = username.lstrip("@").lower()
|
||||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
UsersRound,
|
||||||
} from "$components/ui/icons.js";
|
} from "$components/ui/icons.js";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@
|
|||||||
userDeleteOpen,
|
userDeleteOpen,
|
||||||
userBanConfirmOpen,
|
userBanConfirmOpen,
|
||||||
userMessageConfirmOpen,
|
userMessageConfirmOpen,
|
||||||
|
userReferralsOpen,
|
||||||
|
userReferralsLoading,
|
||||||
|
userReferrals,
|
||||||
|
userReferralsTotal,
|
||||||
|
userReferralsPage,
|
||||||
|
userReferralsPageSize,
|
||||||
premiumUnlimitedDraft,
|
premiumUnlimitedDraft,
|
||||||
userDetailTab,
|
userDetailTab,
|
||||||
userLogs,
|
userLogs,
|
||||||
@@ -75,8 +82,13 @@
|
|||||||
|
|
||||||
$: userLogsHasMore =
|
$: userLogsHasMore =
|
||||||
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
|
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) : "";
|
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
|
||||||
|
$: referralInviter = openedUserDetail?.referral?.inviter || null;
|
||||||
|
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
|
||||||
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
|
||||||
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
|
||||||
$: openedUserTelegramProfileHint =
|
$: openedUserTelegramProfileHint =
|
||||||
@@ -119,6 +131,12 @@
|
|||||||
}
|
}
|
||||||
openTelegramProfileLink(openedUserTelegramProfileLink);
|
openTelegramProfileLink(openedUserTelegramProfileLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRelatedUser(user) {
|
||||||
|
if (!user?.user_id) return;
|
||||||
|
usersStore.closeUserReferrals();
|
||||||
|
usersStore.openUser(user);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -225,6 +243,41 @@
|
|||||||
"—"}</strong
|
"—"}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
|
||||||
@@ -934,6 +987,101 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={avatarPreviewOpen}
|
open={avatarPreviewOpen}
|
||||||
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
|
||||||
@@ -1159,6 +1307,62 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 6px;
|
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) {
|
:global(.admin-avatar-dialog) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsPageSize: USERS_PAGE_SIZE,
|
||||||
|
userReferralsInviter: null,
|
||||||
userDetailTab: "profile",
|
userDetailTab: "profile",
|
||||||
premiumUnlimitedDraft: false,
|
premiumUnlimitedDraft: false,
|
||||||
premiumBonusGbDraft: "",
|
premiumBonusGbDraft: "",
|
||||||
@@ -126,6 +133,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userExtendDays: 30,
|
userExtendDays: 30,
|
||||||
userDetailLoading: true,
|
userDetailLoading: true,
|
||||||
userDetailTab: "subscription",
|
userDetailTab: "subscription",
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -175,6 +188,12 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
userDeleteOpen: false,
|
userDeleteOpen: false,
|
||||||
userBanConfirmOpen: false,
|
userBanConfirmOpen: false,
|
||||||
userMessageConfirmOpen: false,
|
userMessageConfirmOpen: false,
|
||||||
|
userReferralsOpen: false,
|
||||||
|
userReferralsLoading: false,
|
||||||
|
userReferrals: [],
|
||||||
|
userReferralsTotal: 0,
|
||||||
|
userReferralsPage: 0,
|
||||||
|
userReferralsInviter: null,
|
||||||
userLogs: [],
|
userLogs: [],
|
||||||
userLogsTotal: 0,
|
userLogsTotal: 0,
|
||||||
userLogsPage: 0,
|
userLogsPage: 0,
|
||||||
@@ -231,6 +250,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
loadUserLogs(page);
|
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", {}, "Скопировано")) {
|
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||||
@@ -564,5 +635,8 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
grantTraffic,
|
grantTraffic,
|
||||||
loadUserLogs,
|
loadUserLogs,
|
||||||
setUserLogsPage,
|
setUserLogsPage,
|
||||||
|
openUserReferrals,
|
||||||
|
closeUserReferrals,
|
||||||
|
setUserReferralsPage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,32 @@ function withDemoAvatars(users, size = 96) {
|
|||||||
return (users || []).map((user) => withDemoAvatar(user, size));
|
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) {
|
function withDemoAvatarTickets(tickets, size = 96) {
|
||||||
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
|
return (tickets || []).map((ticket) => withDemoAvatarTicket(ticket, size));
|
||||||
}
|
}
|
||||||
@@ -610,8 +636,21 @@ function demoApiResponse(path, cleanPath, options, context) {
|
|||||||
const id = Number(parts[3]);
|
const id = Number(parts[3]);
|
||||||
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
|
const detail = DEMO_DATASET.adminUserDetails?.[String(id)];
|
||||||
if (!detail) return { ok: false, error: "not_found" };
|
if (!detail) return { ok: false, error: "not_found" };
|
||||||
const decoratedDetail = withDemoAvatarDetail(detail);
|
const decoratedDetail = withDemoReferralSummary(detail);
|
||||||
if (parts[4]) {
|
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") {
|
if (parts[4] === "telegram-profile-link") {
|
||||||
return { ok: true, url: `https://t.me/${detail.user?.username || "demo_user"}` };
|
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_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</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_links_section_title": "🔗 <b>Links</b>",
|
||||||
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
|
"admin_user_subscription_url_label": "📡 <b>Subscription:</b>",
|
||||||
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
|
"admin_user_ref_bot_link_label": "🤖 <b>Referral link (bot):</b>",
|
||||||
@@ -1247,6 +1254,14 @@
|
|||||||
"admin_user_section_profile": "Profile",
|
"admin_user_section_profile": "Profile",
|
||||||
"admin_user_label_registration": "Registration",
|
"admin_user_label_registration": "Registration",
|
||||||
"admin_user_label_ref_code": "Referral Code",
|
"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_section_links": "Links",
|
||||||
"admin_user_copy_tooltip": "Copy",
|
"admin_user_copy_tooltip": "Copy",
|
||||||
"admin_user_sub_link_copied": "Subscription link copied",
|
"admin_user_sub_link_copied": "Subscription link copied",
|
||||||
@@ -1373,6 +1388,9 @@
|
|||||||
"admin_ads_col_registrations": "Registrations",
|
"admin_ads_col_registrations": "Registrations",
|
||||||
"admin_ads_col_conversions": "Conversions",
|
"admin_ads_col_conversions": "Conversions",
|
||||||
"admin_no_data": "No data",
|
"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_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_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.",
|
"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_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||||
"admin_user_ref_purchased_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_links_section_title": "🔗 <b>Ссылки</b>",
|
||||||
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
|
"admin_user_subscription_url_label": "📡 <b>Подписка:</b>",
|
||||||
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
|
"admin_user_ref_bot_link_label": "🤖 <b>Реф. ссылка (бот):</b>",
|
||||||
@@ -1247,6 +1254,14 @@
|
|||||||
"admin_user_section_profile": "Профиль",
|
"admin_user_section_profile": "Профиль",
|
||||||
"admin_user_label_registration": "Регистрация",
|
"admin_user_label_registration": "Регистрация",
|
||||||
"admin_user_label_ref_code": "Реф. код",
|
"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_section_links": "Ссылки",
|
||||||
"admin_user_copy_tooltip": "Скопировать",
|
"admin_user_copy_tooltip": "Скопировать",
|
||||||
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
|
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
|
||||||
@@ -1373,6 +1388,9 @@
|
|||||||
"admin_ads_col_registrations": "Регистрации",
|
"admin_ads_col_registrations": "Регистрации",
|
||||||
"admin_ads_col_conversions": "Конверсии",
|
"admin_ads_col_conversions": "Конверсии",
|
||||||
"admin_no_data": "Нет данных",
|
"admin_no_data": "Нет данных",
|
||||||
|
"admin_pagination_meta": "{current}/{total}",
|
||||||
|
"admin_prev_page": "Назад",
|
||||||
|
"admin_next_page": "Вперёд",
|
||||||
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
||||||
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
||||||
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
|
"admin_settings_legacy_tariffs_warning_body": "Эти поля нужны только для старого режима без JSON-каталога. Если тарифы настроены через отдельный раздел «Тарифы», они не применяются.",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class FakeResult:
|
|||||||
def scalar_one_or_none(self):
|
def scalar_one_or_none(self):
|
||||||
return self._scalar_value
|
return self._scalar_value
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self._scalar_value
|
||||||
|
|
||||||
def scalars(self):
|
def scalars(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -71,6 +74,53 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("TRIAL", sql)
|
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):
|
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):
|
||||||
user = SimpleNamespace(user_id=42, panel_user_uuid="panel-main")
|
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/stats"): "admin_stats_route",
|
||||||
("GET", "/api/admin/users"): "admin_users_list_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}"): "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",
|
("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}/ban"): "admin_user_ban_route",
|
||||||
("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",
|
("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user