From 687fc03e8c5a31b44fc1f8e8b1827af5caad3ae5 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 19:14:54 +0300 Subject: [PATCH] fix: reset trial eligibility from web admin --- backend/bot/app/web/admin_api_impl/users.py | 15 ++-- backend/bot/app/web/webapp/serializers.py | 2 +- backend/bot/handlers/admin/user_management.py | 3 +- backend/bot/handlers/user/start.py | 8 +- backend/bot/handlers/user/trial_handler.py | 16 ++-- .../subscription_service_impl/payments.py | 3 + .../subscription_service_impl/trial.py | 2 +- backend/db/dal/subscription_dal.py | 28 ++++++- backend/db/dal/user_dal.py | 14 ++++ backend/db/migrator.py | 14 ++++ backend/db/models.py | 1 + frontend/src/lib/admin/stores/usersStore.js | 7 +- tests/test_admin_user_reset_trial.py | 77 +++++++++++++++++++ tests/test_subscription_service_behavior.py | 45 ++++++++++- tests/test_support_migration.py | 10 +++ tests/test_user_dal.py | 40 +++++++++- 16 files changed, 253 insertions(+), 32 deletions(-) create mode 100644 tests/test_admin_user_reset_trial.py diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index f578cbd..31587c4 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1172,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) settings: Settings = request.app["settings"] - panel_service = request.app.get("panel_service") - subscription_service = request.app.get("subscription_service") - if panel_service is None or subscription_service is None: - return _error(503, "service_unavailable") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -1183,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: if not user: return _error(404, "not_found") - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if active: - await session.delete(active) + reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id) + if reset_at is None: + await session.rollback() + return _error(404, "not_found") - await message_log_dal.create_message_log( + await message_log_dal.create_message_log_no_commit( session, { "user_id": actor_id, "event_type": "admin_reset_trial_webapp", - "content": f"Reset trial for user_id={target_id}", + "content": f"Reset trial eligibility for user_id={target_id}", "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index b08a98d..075daed 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A trial_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 - and not await subscription_service.has_had_any_subscription(session, user_id) + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index cc29674..de463bf 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -932,8 +932,7 @@ async def handle_reset_trial( _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) try: - # Delete all user subscriptions to reset trial eligibility - await subscription_dal.delete_all_user_subscriptions(session, user.user_id) + await user_dal.mark_trial_eligibility_reset(session, user.user_id) await session.commit() await callback.answer(_("admin_user_trial_reset_success"), show_alert=True) diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 9db11a5..a44d2d2 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -45,12 +45,12 @@ async def should_show_trial_button( if not settings.TRIAL_ENABLED: return False - if hasattr(subscription_service, "has_had_any_subscription") and callable( - getattr(subscription_service, "has_had_any_subscription") + if hasattr(subscription_service, "has_trial_blocking_subscription") and callable( + getattr(subscription_service, "has_trial_blocking_subscription") ): - return not await subscription_service.has_had_any_subscription(session, user_id) + return not await subscription_service.has_trial_blocking_subscription(session, user_id) - logging.error("Method has_had_any_subscription is missing in SubscriptionService!") + logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!") return False diff --git a/backend/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py index dd99177..767a9ef 100644 --- a/backend/bot/handlers/user/trial_handler.py +++ b/backend/bot/handlers/user/trial_handler.py @@ -46,7 +46,7 @@ async def request_trial_confirmation_handler( return if settings.TRIAL_ENABLED: - if not await subscription_service.has_had_any_subscription(session, user_id): + if not await subscription_service.has_trial_blocking_subscription(session, user_id): pass if not settings.TRIAL_ENABLED: @@ -60,7 +60,7 @@ async def request_trial_confirmation_handler( pass return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): await callback.message.edit_text( _("trial_already_had_subscription_or_trial"), reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False), @@ -147,8 +147,9 @@ async def request_trial_confirmation_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True @@ -218,7 +219,7 @@ async def confirm_activate_trial_handler( callback, settings, i18n_data, subscription_service, session, is_edit=True ) return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): try: await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True) except Exception: @@ -283,8 +284,9 @@ async def confirm_activate_trial_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 2e48814..012c4c4 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -62,6 +62,9 @@ class PaymentContextMixin: async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: return await subscription_dal.has_any_subscription_for_user(session, user_id) + async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool: + return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id) + async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: """Return True if user currently has an active subscription (end_date in future).""" try: diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index 636e042..a963a85 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -22,7 +22,7 @@ class TrialSubscriptionMixin: "message_key": "user_not_found_for_trial", } - if await self.has_had_any_subscription(session, user_id): + if await self.has_trial_blocking_subscription(session, user_id): return { "eligible": False, "activated": False, diff --git a/backend/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py index a59d900..d81a723 100644 --- a/backend/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -4,12 +4,12 @@ import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional -from sqlalchemy import delete, func, or_, update +from sqlalchemy import and_, delete, func, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from db.models import Subscription, SubscriptionNotification +from db.models import Subscription, SubscriptionNotification, User INSTALL_SHARE_TOKEN_BYTES = 16 @@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int) async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int: - """Completely delete all user subscriptions (for trial reset)""" + """Completely delete all user subscriptions.""" stmt = delete(Subscription).where(Subscription.user_id == user_id) result = await session.execute(stmt) if result.rowcount > 0: @@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) -> return result.scalar_one_or_none() is not None +async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool: + now_utc = datetime.now(timezone.utc) + reset_at = ( + select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery() + ) + subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date) + stmt = ( + select(Subscription.subscription_id) + .where( + Subscription.user_id == user_id, + or_( + reset_at.is_(None), + and_(Subscription.is_active == True, Subscription.end_date > now_utc), + subscription_anchor > reset_at, + ), + ) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def get_subscriptions_near_expiration( session: AsyncSession, days_threshold: int ) -> List[Subscription]: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 4d0308b..6a3c1af 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -261,6 +261,20 @@ async def create_email_user( ) +async def mark_trial_eligibility_reset( + session: AsyncSession, + user_id: int, + *, + reset_at: Optional[datetime] = None, +) -> Optional[datetime]: + reset_at = reset_at or datetime.now(timezone.utc) + stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at) + result = await session.execute(stmt) + if result.rowcount <= 0: + return None + return reset_at + + async def _has_active_panel_subscription( session: AsyncSession, user_id: int, panel_user_uuid: str ) -> bool: diff --git a/backend/db/migrator.py b/backend/db/migrator.py index c09e732..73b3e3c 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) -> connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}")) +def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + if "trial_eligibility_reset_at" not in columns: + connection.execute( + text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ") + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [ description="Track whether the bot can message Telegram-linked users", upgrade=_migration_0032_add_telegram_notification_status, ), + Migration( + id="0033_add_trial_eligibility_reset_marker", + description="Track admin resets of per-user trial eligibility without deleting history", + upgrade=_migration_0033_add_trial_eligibility_reset_marker, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 209abde..78bb8c3 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -47,6 +47,7 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) + trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified_for = Column(BigInteger, nullable=True) diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 8c8b1e6..9529e0e 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -469,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { state.update((st) => ({ ...st, userActionBusy: true })); try { const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" }); - if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен")); - else onToast(res?.error || at("error", {}, "Ошибка")); + if (res?.ok) { + onToast(at("trial_reset", {}, "Триал сброшен")); + await openUser(s.openedUser.user_id, { skipPush: true, pathContext: _pathContext }); + if (_activeRef === "users") await loadUsers(); + } else onToast(res?.error || at("error", {}, "Ошибка")); } finally { state.update((st) => ({ ...st, userActionBusy: false })); } diff --git a/tests/test_admin_user_reset_trial.py b/tests/test_admin_user_reset_trial.py new file mode 100644 index 0000000..8954bc2 --- /dev/null +++ b/tests/test_admin_user_reset_trial.py @@ -0,0 +1,77 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.admin_api_impl import users as admin_users + + +class FakeSession: + def __init__(self): + self.committed = False + self.rolled_back = 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): + self.rolled_back = True + + +class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase): + def _request(self, session: FakeSession): + return SimpleNamespace( + app={ + "settings": SimpleNamespace(), + "async_session_factory": lambda: session, + }, + match_info={"user_id": "42"}, + ) + + async def test_marks_trial_reset_without_deleting_subscription_history(self): + session = FakeSession() + request = self._request(session) + user = SimpleNamespace(user_id=42) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + admin_users.user_dal, + "mark_trial_eligibility_reset", + AsyncMock(return_value=object()), + ) as mark_reset, + patch.object( + admin_users.subscription_dal, + "delete_all_user_subscriptions", + AsyncMock(), + ) as delete_all, + patch.object( + admin_users.message_log_dal, "create_message_log_no_commit", AsyncMock() + ) as log, + patch.object( + admin_users, "_invalidate_after_admin_user_mutation", AsyncMock() + ) as invalidate, + ): + response = await admin_users.admin_user_reset_trial_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.text)["ok"], True) + mark_reset.assert_awaited_once_with(session, 42) + delete_all.assert_not_awaited() + log_payload = log.await_args.args[1] + self.assertEqual(log_payload["event_type"], "admin_reset_trial_webapp") + self.assertEqual(log_payload["target_user_id"], 42) + invalidate.assert_awaited_once() + self.assertTrue(session.committed) + self.assertFalse(session.rolled_back) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 9453289..a3eb2a5 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -202,7 +202,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas TRIAL_SQUAD_UUIDS="trial-squad", ) service = _make_service(settings) - service.has_had_any_subscription = AsyncMock(return_value=False) + service.has_trial_blocking_subscription = AsyncMock(return_value=False) service._get_or_create_panel_user_link_details = AsyncMock( return_value=("panel-user", "panel-sub", "short", True) ) @@ -255,7 +255,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas TRIAL_SQUAD_UUIDS=" , ", ) service = _make_service(settings) - service.has_had_any_subscription = AsyncMock(return_value=False) + service.has_trial_blocking_subscription = AsyncMock(return_value=False) service._get_or_create_panel_user_link_details = AsyncMock( return_value=("panel-user", "panel-sub", "short", True) ) @@ -292,6 +292,47 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"]) + async def test_activate_trial_rejects_users_with_blocking_subscription_history(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=3, + ) + service = _make_service(settings) + service.has_trial_blocking_subscription = AsyncMock(return_value=True) + service._get_or_create_panel_user_link_details = AsyncMock() + service.panel_service.update_user_details_on_panel = AsyncMock() + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="trial-user", + first_name="Trial", + last_name="User", + ) + + with ( + patch( + "bot.services.subscription_service_impl.trial.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription", + AsyncMock(), + ) as upsert_subscription, + ): + result = await service.activate_trial_subscription(session, user_id=42) + + self.assertFalse(result["activated"]) + self.assertFalse(result["eligible"]) + self.assertEqual(result["message_key"], "trial_already_had_subscription_or_trial") + service._get_or_create_panel_user_link_details.assert_not_awaited() + service.panel_service.update_user_details_on_panel.assert_not_awaited() + upsert_subscription.assert_not_awaited() + async def test_activate_subscription_dispatches_traffic_sale_mode(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) diff --git a/tests/test_support_migration.py b/tests/test_support_migration.py index ec113a2..5fa5292 100644 --- a/tests/test_support_migration.py +++ b/tests/test_support_migration.py @@ -29,3 +29,13 @@ def test_support_models_expose_expected_tables(): def test_user_model_tracks_lifetime_traffic_sync_timestamp(): assert "lifetime_used_traffic_synced_at" in User.__table__.columns + + +def test_trial_eligibility_reset_migration_and_model_are_registered(): + ids = [migration.id for migration in MIGRATIONS] + + assert "0033_add_trial_eligibility_reset_marker" in ids + assert ids.index("0033_add_trial_eligibility_reset_marker") > ids.index( + "0032_add_telegram_notification_status" + ) + assert "trial_eligibility_reset_at" in User.__table__.columns diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index ede5e3f..5b8bd30 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from sqlalchemy.dialects import postgresql from sqlalchemy.sql.dml import Delete, Update -from db.dal import user_dal +from db.dal import subscription_dal, user_dal class FakeResult: @@ -122,6 +122,44 @@ class UserDalReferralTests(unittest.IsolatedAsyncioTestCase): self.assertIs(result, referrer) + async def test_mark_trial_eligibility_reset_updates_user_marker(self): + reset_at = datetime(2026, 6, 1, tzinfo=timezone.utc) + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(rowcount=1))) + + result = await user_dal.mark_trial_eligibility_reset(session, 42, reset_at=reset_at) + + self.assertEqual(result, reset_at) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("UPDATE USERS", sql) + self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql) + self.assertIn("USER_ID = 42", sql) + + +class SubscriptionDalTrialEligibilityTests(unittest.IsolatedAsyncioTestCase): + async def test_trial_blocking_history_honors_user_reset_marker(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(7))) + + result = await subscription_dal.has_trial_blocking_subscription_for_user(session, 42) + + self.assertTrue(result) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql) + self.assertIn("SUBSCRIPTIONS.IS_ACTIVE = TRUE", sql) + self.assertIn("COALESCE(SUBSCRIPTIONS.START_DATE, SUBSCRIPTIONS.END_DATE)", sql) + self.assertIn("SUBSCRIPTIONS.USER_ID = 42", sql) + class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):