fix(admin): show trial history and log activations
This commit is contained in:
@@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
|||||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||||
|
provider = sub.provider
|
||||||
|
is_trial = str(provider or "").strip().lower() == "trial"
|
||||||
|
display_label = "Trial" if is_trial else sub.tariff_key
|
||||||
return {
|
return {
|
||||||
"subscription_id": int(sub.subscription_id),
|
"subscription_id": int(sub.subscription_id),
|
||||||
"panel_user_uuid": sub.panel_user_uuid,
|
"panel_user_uuid": sub.panel_user_uuid,
|
||||||
@@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
|||||||
"premium_unlimited_override": premium_unlimited_override,
|
"premium_unlimited_override": premium_unlimited_override,
|
||||||
"premium_is_limited": bool(sub.premium_is_limited),
|
"premium_is_limited": bool(sub.premium_is_limited),
|
||||||
"tariff_key": sub.tariff_key,
|
"tariff_key": sub.tariff_key,
|
||||||
|
"display_label": display_label,
|
||||||
|
"is_trial": is_trial,
|
||||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||||
"provider": sub.provider,
|
"provider": provider,
|
||||||
"is_throttled": bool(sub.is_throttled),
|
"is_throttled": bool(sub.is_throttled),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -745,6 +745,24 @@ def _user_search_condition(query: str):
|
|||||||
return or_(*conditions)
|
return or_(*conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
|
||||||
|
first_trial_sub = trial_subs[0] if trial_subs else None
|
||||||
|
latest_trial_sub = trial_subs[-1] if trial_subs else None
|
||||||
|
first_start = getattr(first_trial_sub, "start_date", None)
|
||||||
|
latest_start = getattr(latest_trial_sub, "start_date", None)
|
||||||
|
latest_end = getattr(latest_trial_sub, "end_date", None)
|
||||||
|
reset_at = getattr(user, "trial_eligibility_reset_at", None)
|
||||||
|
return {
|
||||||
|
"used": bool(trial_subs),
|
||||||
|
"count": len(trial_subs),
|
||||||
|
"first_activated_at": first_start.isoformat() if first_start else None,
|
||||||
|
"latest_activated_at": latest_start.isoformat() if latest_start else None,
|
||||||
|
"latest_end_date": latest_end.isoformat() if latest_end else None,
|
||||||
|
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
|
||||||
|
"last_reset_at": reset_at.isoformat() if reset_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
async def admin_user_detail_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"])
|
||||||
@@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||||
|
trial_subs_stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.user_id == target_id,
|
||||||
|
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
|
||||||
|
)
|
||||||
|
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
|
||||||
|
)
|
||||||
|
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
|
||||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||||
recent_payments_stmt = (
|
recent_payments_stmt = (
|
||||||
select(Payment)
|
select(Payment)
|
||||||
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
serialized_inviter = (
|
serialized_inviter = (
|
||||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||||
)
|
)
|
||||||
|
trial_payload = _serialize_trial_summary(user, trial_subs)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"user": serialized_user,
|
"user": serialized_user,
|
||||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||||
|
"trial": trial_payload,
|
||||||
"total_paid": float(total_paid),
|
"total_paid": float(total_paid),
|
||||||
"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),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
|
from db.dal import message_log_dal
|
||||||
|
|
||||||
|
|
||||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||||
@@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send WebApp trial activation notification")
|
logger.exception("Failed to send WebApp trial activation notification")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"telegram_username": getattr(db_user, "username", None),
|
||||||
|
"telegram_first_name": getattr(db_user, "first_name", None),
|
||||||
|
"event_type": "webapp_trial_activate",
|
||||||
|
"content": (
|
||||||
|
f"Trial activated via WebApp for user_id={user_id}; "
|
||||||
|
f"email={getattr(db_user, 'email', None) or 'N/A'}"
|
||||||
|
),
|
||||||
|
"is_admin_event": False,
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to add WebApp trial activation audit log")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from db.dal import ad_dal as _ad_dal
|
from db.dal import ad_dal as _ad_dal
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,26 @@
|
|||||||
return String(val ?? "—");
|
return String(val ?? "—");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTrialSubscription(sub) {
|
||||||
|
return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial");
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionDisplayLabel(sub) {
|
||||||
|
if (!sub) return "—";
|
||||||
|
if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал");
|
||||||
|
if (sub.display_label) return sub.display_label;
|
||||||
|
return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа");
|
||||||
|
}
|
||||||
|
|
||||||
|
function trialSummaryText(trial) {
|
||||||
|
if (!trial?.used) return at("user_trial_not_used", {}, "Не брал");
|
||||||
|
const date = trial.latest_activated_at || trial.first_activated_at;
|
||||||
|
const base = date
|
||||||
|
? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`)
|
||||||
|
: at("user_trial_used", {}, "Брал");
|
||||||
|
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
const usersStore = getContext("usersStore");
|
const usersStore = getContext("usersStore");
|
||||||
|
|
||||||
$: ({
|
$: ({
|
||||||
@@ -404,7 +424,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong
|
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong
|
||||||
>{openedUserDetail.active_subscription.tariff_key || "—"}</strong
|
>{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -507,6 +527,37 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if openedUserDetail?.trial}
|
||||||
|
<ul class="admin-meta-list">
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_trial", {}, "Пробник / триал")}</span><strong
|
||||||
|
>{trialSummaryText(openedUserDetail.trial)}</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{#if openedUserDetail.trial.used && openedUserDetail.trial.latest_end_date}
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_trial_until", {}, "Триал до")}</span><strong
|
||||||
|
>{fmtDate(openedUserDetail.trial.latest_end_date)}</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if Number(openedUserDetail.trial.count || 0) > 1}
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_trial_count", {}, "Триалов")}</span><strong
|
||||||
|
>{openedUserDetail.trial.count}</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if openedUserDetail.trial.last_reset_at}
|
||||||
|
<li>
|
||||||
|
<span>{at("user_label_trial_reset_at", {}, "Сброс триала")}</span><strong
|
||||||
|
>{fmtDate(openedUserDetail.trial.last_reset_at)}</strong
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if (openedUserDetail.subscriptions || []).length}
|
{#if (openedUserDetail.subscriptions || []).length}
|
||||||
<Separator.Root class="admin-separator" />
|
<Separator.Root class="admin-separator" />
|
||||||
<div class="admin-subsection-title">
|
<div class="admin-subsection-title">
|
||||||
@@ -521,8 +572,7 @@
|
|||||||
<div class="admin-mini-list-row">
|
<div class="admin-mini-list-row">
|
||||||
<div>
|
<div>
|
||||||
<strong
|
<strong
|
||||||
>{sub.tariff_key ||
|
>{subscriptionDisplayLabel(sub)}</strong
|
||||||
at("user_history_no_tariff", {}, "Без тарифа")}</strong
|
|
||||||
>
|
>
|
||||||
<small
|
<small
|
||||||
>{at(
|
>{at(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from bot.app.web.admin_api_impl import common as admin_common
|
||||||
from bot.app.web.admin_api_impl import users as admin_users
|
from bot.app.web.admin_api_impl import users as admin_users
|
||||||
|
|
||||||
|
|
||||||
@@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertFalse(session.rolled_back)
|
self.assertFalse(session.rolled_back)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserTrialPresentationTests(unittest.TestCase):
|
||||||
|
def test_trial_subscription_serializes_display_label(self):
|
||||||
|
start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
|
||||||
|
end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
|
||||||
|
sub = SimpleNamespace(
|
||||||
|
subscription_id=7,
|
||||||
|
panel_user_uuid="panel-user",
|
||||||
|
panel_subscription_uuid=None,
|
||||||
|
start_date=start_at,
|
||||||
|
end_date=end_at,
|
||||||
|
duration_months=None,
|
||||||
|
is_active=False,
|
||||||
|
status_from_panel="EXPIRED",
|
||||||
|
traffic_limit_bytes=10,
|
||||||
|
traffic_used_bytes=2,
|
||||||
|
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=None,
|
||||||
|
auto_renew_enabled=False,
|
||||||
|
provider="trial",
|
||||||
|
is_throttled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = admin_common._serialize_subscription(sub)
|
||||||
|
|
||||||
|
self.assertTrue(payload["is_trial"])
|
||||||
|
self.assertEqual(payload["display_label"], "Trial")
|
||||||
|
self.assertIsNone(payload["tariff_key"])
|
||||||
|
|
||||||
|
def test_trial_summary_includes_usage_dates_and_reset_marker(self):
|
||||||
|
first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
|
||||||
|
latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||||
|
latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc)
|
||||||
|
reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||||
|
user = SimpleNamespace(trial_eligibility_reset_at=reset_at)
|
||||||
|
trial_subs = [
|
||||||
|
SimpleNamespace(
|
||||||
|
start_date=first_at,
|
||||||
|
end_date=datetime(2026, 1, 9, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = admin_users._serialize_trial_summary(user, trial_subs)
|
||||||
|
|
||||||
|
self.assertTrue(payload["used"])
|
||||||
|
self.assertTrue(payload["active"])
|
||||||
|
self.assertEqual(payload["count"], 2)
|
||||||
|
self.assertEqual(payload["first_activated_at"], first_at.isoformat())
|
||||||
|
self.assertEqual(payload["latest_activated_at"], latest_at.isoformat())
|
||||||
|
self.assertEqual(payload["latest_end_date"], latest_end.isoformat())
|
||||||
|
self.assertEqual(payload["last_reset_at"], reset_at.isoformat())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import IsolatedAsyncioTestCase
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import bot.app.web.subscription_webapp # noqa: F401
|
||||||
|
from bot.app.web.webapp import billing as billing_module
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self):
|
||||||
|
self.commit_count = 0
|
||||||
|
self.rollback_count = 0
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.commit_count += 1
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
self.rollback_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionFactory:
|
||||||
|
def __init__(self, session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppTrialActivationTests(IsolatedAsyncioTestCase):
|
||||||
|
async def test_email_only_trial_activation_is_written_to_admin_logs(self):
|
||||||
|
session = _Session()
|
||||||
|
end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
TRIAL_ENABLED=True,
|
||||||
|
TRIAL_DURATION_DAYS=7,
|
||||||
|
TRIAL_TRAFFIC_LIMIT_GB=10,
|
||||||
|
LOG_TRIAL_ACTIVATIONS=False,
|
||||||
|
)
|
||||||
|
db_user = SimpleNamespace(
|
||||||
|
user_id=42,
|
||||||
|
is_banned=False,
|
||||||
|
username=None,
|
||||||
|
first_name=None,
|
||||||
|
email="email-only@example.com",
|
||||||
|
)
|
||||||
|
subscription_service = SimpleNamespace(
|
||||||
|
activate_trial_subscription=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"activated": True,
|
||||||
|
"days": 7,
|
||||||
|
"end_date": end_date,
|
||||||
|
"traffic_gb": 10,
|
||||||
|
"subscription_url": "https://panel.example/sub",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
app={
|
||||||
|
"settings": settings,
|
||||||
|
"async_session_factory": _SessionFactory(session),
|
||||||
|
"subscription_service": subscription_service,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(billing_module, "_require_user_id", return_value=42),
|
||||||
|
patch.object(
|
||||||
|
billing_module,
|
||||||
|
"_enforce_webapp_rate_limit",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
billing_module.user_dal,
|
||||||
|
"get_user_by_id",
|
||||||
|
AsyncMock(return_value=db_user),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
billing_module,
|
||||||
|
"prepare_config_links",
|
||||||
|
AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
billing_module.message_log_dal,
|
||||||
|
"create_message_log_no_commit",
|
||||||
|
AsyncMock(),
|
||||||
|
) as create_log,
|
||||||
|
patch.object(
|
||||||
|
billing_module,
|
||||||
|
"invalidate_webapp_user_caches",
|
||||||
|
AsyncMock(),
|
||||||
|
),
|
||||||
|
patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated,
|
||||||
|
):
|
||||||
|
response = await billing_module.activate_trial_route(request)
|
||||||
|
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertTrue(payload["activated"])
|
||||||
|
subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42)
|
||||||
|
create_log.assert_awaited_once()
|
||||||
|
log_payload = create_log.await_args.args[1]
|
||||||
|
self.assertEqual(log_payload["user_id"], 42)
|
||||||
|
self.assertEqual(log_payload["target_user_id"], 42)
|
||||||
|
self.assertEqual(log_payload["event_type"], "webapp_trial_activate")
|
||||||
|
self.assertFalse(log_payload["is_admin_event"])
|
||||||
|
self.assertIn("email-only@example.com", log_payload["content"])
|
||||||
|
mark_trial_activated.assert_awaited_once_with(session, 42)
|
||||||
|
self.assertEqual(session.commit_count, 2)
|
||||||
|
self.assertEqual(session.rollback_count, 0)
|
||||||
Reference in New Issue
Block a user