fix(admin): show trial history and log activations

This commit is contained in:
3252a8
2026-06-02 12:57:15 +03:00
parent ee840a7e6d
commit 1ee8af338c
6 changed files with 293 additions and 4 deletions
+6 -1
View File
@@ -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),
+23
View File
@@ -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(
+65
View File
@@ -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()
+117
View File
@@ -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)