feat: show user vpn connection activity
This commit is contained in:
@@ -5,6 +5,7 @@ from .common import (
|
|||||||
_build_admin_webapp_referral_link,
|
_build_admin_webapp_referral_link,
|
||||||
_error,
|
_error,
|
||||||
_ok,
|
_ok,
|
||||||
|
_panel_user_connection_activity,
|
||||||
_premium_traffic_list_payload,
|
_premium_traffic_list_payload,
|
||||||
_read_json,
|
_read_json,
|
||||||
_serialize_payment,
|
_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
|
# imports into their VPN client. May be missing if the user has never
|
||||||
# been provisioned on the panel.
|
# been provisioned on the panel.
|
||||||
subscription_url: Optional[str] = None
|
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:
|
if panel_uuid:
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
panel_service = getattr(subscription_service, "panel_service", None)
|
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)
|
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||||
if panel_data:
|
if panel_data:
|
||||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
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
|
except Exception as exc_panel: # pragma: no cover
|
||||||
logger.warning(
|
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,
|
target_id,
|
||||||
panel_uuid,
|
panel_uuid,
|
||||||
exc_panel,
|
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],
|
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||||
"log_count": int(log_count or 0),
|
"log_count": int(log_count or 0),
|
||||||
"subscription_url": subscription_url,
|
"subscription_url": subscription_url,
|
||||||
|
"last_vpn_connected_at": last_vpn_connected_at,
|
||||||
|
"vpn_connection_status": vpn_connection_status,
|
||||||
"referral": {
|
"referral": {
|
||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_bot_link,
|
"bot_link": referral_bot_link,
|
||||||
|
|||||||
@@ -90,6 +90,17 @@
|
|||||||
return at("user_hwid_limit_count", { count: base }, `${base}`);
|
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");
|
const usersStore = getContext("usersStore");
|
||||||
|
|
||||||
$: ({
|
$: ({
|
||||||
@@ -274,6 +285,10 @@
|
|||||||
>{fmtDate(openedUser.registration_date)}</strong
|
>{fmtDate(openedUser.registration_date)}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_vpn_last_connected", {}, "Последнее VPN-подключение")}</span
|
||||||
|
><strong>{vpnLastConnectionLabel(openedUserDetail)}</strong>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>{at("user_label_ref_code", {}, "Реф. код")}</span><strong
|
<span>{at("user_label_ref_code", {}, "Реф. код")}</span><strong
|
||||||
>{openedUserDetail.referral?.code ||
|
>{openedUserDetail.referral?.code ||
|
||||||
|
|||||||
@@ -1529,6 +1529,8 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
],
|
],
|
||||||
log_count: 18,
|
log_count: 18,
|
||||||
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
|
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
|
||||||
|
last_vpn_connected_at: "2026-06-05T08:42:00Z",
|
||||||
|
vpn_connection_status: "connected",
|
||||||
referral: {
|
referral: {
|
||||||
code: "ABCD1234",
|
code: "ABCD1234",
|
||||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||||
|
|||||||
@@ -1333,6 +1333,9 @@
|
|||||||
"admin_user_label_logs": "Logs",
|
"admin_user_label_logs": "Logs",
|
||||||
"admin_user_section_profile": "Profile",
|
"admin_user_section_profile": "Profile",
|
||||||
"admin_user_label_registration": "Registration",
|
"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_ref_code": "Referral Code",
|
||||||
"admin_user_label_invited_by": "Invited by",
|
"admin_user_label_invited_by": "Invited by",
|
||||||
"admin_user_label_invited_users": "Invited users",
|
"admin_user_label_invited_users": "Invited users",
|
||||||
|
|||||||
@@ -1333,6 +1333,9 @@
|
|||||||
"admin_user_label_logs": "Логов",
|
"admin_user_label_logs": "Логов",
|
||||||
"admin_user_section_profile": "Профиль",
|
"admin_user_section_profile": "Профиль",
|
||||||
"admin_user_label_registration": "Регистрация",
|
"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_ref_code": "Реф. код",
|
||||||
"admin_user_label_invited_by": "Пригласил",
|
"admin_user_label_invited_by": "Пригласил",
|
||||||
"admin_user_label_invited_users": "Приглашённые",
|
"admin_user_label_invited_users": "Приглашённые",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
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 broadcast as broadcast_module
|
||||||
from bot.app.web.admin_api_impl import common as common_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:
|
class FakeResult:
|
||||||
@@ -13,6 +16,57 @@ class FakeResult:
|
|||||||
def all(self):
|
def all(self):
|
||||||
return self._rows
|
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):
|
class AdminPanelActivityTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def test_panel_activity_detects_connected_and_never_connected_users(self):
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user