From 6890b58ceda03b25f07741bceb51c19e82c7307d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 5 Jun 2026 16:14:34 +0300 Subject: [PATCH] feat: show user vpn connection activity --- backend/bot/app/web/admin_api_impl/users.py | 16 ++- .../src/admin/sections/UserDetailModal.svelte | 15 ++ frontend/src/lib/webapp/mockApi.js | 2 + locales/en.json | 3 + locales/ru.json | 3 + tests/test_admin_panel_activity.py | 136 +++++++++++++++++- 6 files changed, 172 insertions(+), 3 deletions(-) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index c6a061d..8c35593 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -5,6 +5,7 @@ from .common import ( _build_admin_webapp_referral_link, _error, _ok, + _panel_user_connection_activity, _premium_traffic_list_payload, _read_json, _serialize_payment, @@ -836,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: # imports into their VPN client. May be missing if the user has never # been provisioned on the panel. subscription_url: Optional[str] = None - panel_uuid = getattr(user, "panel_user_uuid", None) + last_vpn_connected_at: Optional[str] = None + vpn_connection_status = "unknown" + panel_uuid = getattr(user, "panel_user_uuid", None) or getattr( + active_sub, + "panel_user_uuid", + None, + ) if panel_uuid: subscription_service = request.app.get("subscription_service") panel_service = getattr(subscription_service, "panel_service", None) @@ -845,9 +852,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: panel_data = await panel_service.get_user_by_uuid(panel_uuid) if panel_data: subscription_url = panel_data.get("subscriptionUrl") or None + vpn_activity = _panel_user_connection_activity(panel_data) + vpn_connection_status = str(vpn_activity.get("status") or "unknown") + last_vpn_connected_at = vpn_activity.get("last_connected_at") except Exception as exc_panel: # pragma: no cover logger.warning( - "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", + "Failed to fetch panel details for user %s (uuid=%s): %s", target_id, panel_uuid, exc_panel, @@ -869,6 +879,8 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), "subscription_url": subscription_url, + "last_vpn_connected_at": last_vpn_connected_at, + "vpn_connection_status": vpn_connection_status, "referral": { "code": referral_code, "bot_link": referral_bot_link, diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index 1bfac32..6ba4045 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -90,6 +90,17 @@ return at("user_hwid_limit_count", { count: base }, `${base}`); } + function vpnLastConnectionLabel(detail) { + const connectedAt = detail?.last_vpn_connected_at; + const status = detail?.vpn_connection_status; + if (connectedAt) return fmtDate(connectedAt); + if (status === "never") return at("user_vpn_never_connected", {}, "Никогда"); + if (status === "connected") { + return at("user_vpn_connected_no_time", {}, "Подключался, время неизвестно"); + } + return "—"; + } + const usersStore = getContext("usersStore"); $: ({ @@ -274,6 +285,10 @@ >{fmtDate(openedUser.registration_date)} +
  • + {at("user_label_vpn_last_connected", {}, "Последнее VPN-подключение")}{vpnLastConnectionLabel(openedUserDetail)} +
  • {at("user_label_ref_code", {}, "Реф. код")}{openedUserDetail.referral?.code || diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 187d764..1299f68 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1529,6 +1529,8 @@ export async function mockApi(path, options = {}, context = {}) { ], log_count: 18, subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP", + last_vpn_connected_at: "2026-06-05T08:42:00Z", + vpn_connection_status: "connected", referral: { code: "ABCD1234", bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", diff --git a/locales/en.json b/locales/en.json index 19bce94..38dfe30 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1333,6 +1333,9 @@ "admin_user_label_logs": "Logs", "admin_user_section_profile": "Profile", "admin_user_label_registration": "Registration", + "admin_user_label_vpn_last_connected": "Last VPN connection", + "admin_user_vpn_never_connected": "Never", + "admin_user_vpn_connected_no_time": "Connected, time unknown", "admin_user_label_ref_code": "Referral Code", "admin_user_label_invited_by": "Invited by", "admin_user_label_invited_users": "Invited users", diff --git a/locales/ru.json b/locales/ru.json index 031d215..a16664e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1333,6 +1333,9 @@ "admin_user_label_logs": "Логов", "admin_user_section_profile": "Профиль", "admin_user_label_registration": "Регистрация", + "admin_user_label_vpn_last_connected": "Последнее VPN-подключение", + "admin_user_vpn_never_connected": "Никогда", + "admin_user_vpn_connected_no_time": "Подключался, время неизвестно", "admin_user_label_ref_code": "Реф. код", "admin_user_label_invited_by": "Пригласил", "admin_user_label_invited_users": "Приглашённые", diff --git a/tests/test_admin_panel_activity.py b/tests/test_admin_panel_activity.py index be9205b..ce32dfb 100644 --- a/tests/test_admin_panel_activity.py +++ b/tests/test_admin_panel_activity.py @@ -1,9 +1,12 @@ +import json import unittest +from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bot.app.web.admin_api_impl import broadcast as broadcast_module from bot.app.web.admin_api_impl import common as common_module +from bot.app.web.admin_api_impl import users as users_module class FakeResult: @@ -13,6 +16,57 @@ class FakeResult: def all(self): return self._rows + def scalars(self): + return self + + +class FakeSession: + def __init__(self): + self.execute = AsyncMock(side_effect=[FakeResult([]), FakeResult([]), FakeResult([])]) + self.committed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.committed = True + + async def rollback(self): + pass + + +def _active_subscription(panel_user_uuid="panel-from-sub"): + return SimpleNamespace( + subscription_id=10, + panel_user_uuid=panel_user_uuid, + panel_subscription_uuid=None, + start_date=datetime(2026, 6, 1, tzinfo=timezone.utc), + end_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + duration_months=1, + is_active=True, + status_from_panel="ACTIVE", + traffic_limit_bytes=100, + traffic_used_bytes=0, + tier_baseline_bytes=0, + topup_balance_bytes=0, + premium_used_bytes=0, + premium_baseline_bytes=0, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_bonus_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=False, + premium_unlimited_override=False, + premium_is_limited=False, + tariff_key="standard", + auto_renew_enabled=True, + provider="yookassa", + is_throttled=False, + ) + class AdminPanelActivityTests(unittest.IsolatedAsyncioTestCase): def test_panel_activity_detects_connected_and_never_connected_users(self): @@ -95,6 +149,86 @@ class AdminPanelActivityTests(unittest.IsolatedAsyncioTestCase): ], ) + async def test_user_detail_includes_last_vpn_connection_from_panel(self): + session = FakeSession() + user = SimpleNamespace( + user_id=42, + telegram_id=42, + telegram_photo_url=None, + username="alice", + first_name="Alice", + last_name=None, + email=None, + language_code="ru", + is_banned=False, + registration_date=datetime(2026, 6, 1, tzinfo=timezone.utc), + panel_user_uuid=None, + referral_code=None, + referred_by_id=None, + trial_eligibility_reset_at=None, + ) + active_sub = _active_subscription("panel-from-sub") + panel_service = SimpleNamespace( + get_user_by_uuid=AsyncMock( + return_value={ + "subscriptionUrl": "https://panel.example/sub/short", + "onlineAt": "2026-06-05T12:00:00Z", + } + ) + ) + request = SimpleNamespace( + app={ + "settings": SimpleNamespace(SUBSCRIPTION_MINI_APP_URL=None), + "async_session_factory": lambda: session, + "subscription_service": SimpleNamespace(panel_service=panel_service), + }, + match_info={"user_id": "42"}, + ) + + with ( + patch.object(users_module, "_require_admin_user_id", return_value=100), + patch.object(users_module.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + users_module.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=active_sub), + ), + patch.object( + users_module.payment_dal, + "get_user_total_paid", + AsyncMock(return_value=0), + ), + patch.object( + users_module.message_log_dal, + "count_user_message_logs", + AsyncMock(return_value=0), + ), + patch.object( + users_module.user_dal, + "get_referrer_for_user", + AsyncMock(return_value=None), + ), + patch.object( + users_module.user_dal, + "count_users_referred_by", + AsyncMock(return_value=0), + ), + patch.object(users_module, "_bulk_user_avatar_keys", AsyncMock(return_value={})), + patch.object( + users_module.user_dal, + "ensure_referral_code", + AsyncMock(return_value="REF"), + ), + ): + response = await users_module.admin_user_detail_route(request) + + payload = json.loads(response.text) + self.assertEqual(response.status, 200) + self.assertEqual(payload["subscription_url"], "https://panel.example/sub/short") + self.assertEqual(payload["vpn_connection_status"], "connected") + self.assertEqual(payload["last_vpn_connected_at"], "2026-06-05T12:00:00+00:00") + panel_service.get_user_by_uuid.assert_awaited_once_with("panel-from-sub") + if __name__ == "__main__": unittest.main()