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()