From c6c535281367363bd75a17d58763dd2fb0ed2fc2 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 30 May 2026 11:56:06 +0300 Subject: [PATCH] fix: format premium traffic limits from bytes --- .../bot/handlers/user/subscription/core.py | 18 ++++- .../subscription_service_impl/lifecycle.py | 49 ++++++++++++- tests/test_subscription_service_behavior.py | 72 +++++++++++++++++++ tests/test_user_bot_menu.py | 19 ++++- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index 6d2c149..bcb2fa2 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -114,6 +114,20 @@ def _with_subscription_purchase_description( return f"{description}\n\n{text}" +def _format_premium_bytes(value: object) -> str: + try: + bytes_value = max(0, int(value or 0)) + except (TypeError, ValueError): + bytes_value = 0 + return f"{bytes_value / 2**30:.2f} GB" + + +def _format_premium_usage_limit(active: dict[str, object]) -> str: + used = _format_premium_bytes(active.get("premium_used_bytes")) + limit = _format_premium_bytes(active.get("premium_limit_bytes")) + return f"{used} из {limit}" + + async def display_subscription_options( event: Union[types.Message, types.CallbackQuery], i18n_data: dict, @@ -456,7 +470,7 @@ async def tariff_topup_list_callback( if len(labels) > len(visible): premium_lines.append(f"• ... еще {len(labels) - len(visible)}") premium_lines.append( - f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501 + f"Premium использовано: {_format_premium_usage_limit(active)}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501 ) text = get_text("choose_payment_method_traffic") if carryover_lines: @@ -1065,7 +1079,7 @@ async def my_subscription_command_handler( text += ( "\n\n🚀 Premium-серверы\n" f"Статус: {premium_status}\n" - f"Лимит: {active.get('premium_used')} из {active.get('premium_limit')}\n" + f"Лимит: {_format_premium_usage_limit(active)}\n" f"Осталось: {premium_left / 2**30:.2f} GB\n" f"Докупленный остаток: {premium_balance / 2**30:.2f} GB\n" "Отдельный лимит действует на:\n" diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 021c74d..66a4434 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -54,6 +54,37 @@ class SubscriptionLifecycleMixin: reason = f"{reason} message={message}" return reason + @staticmethod + def _display_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + return str(value) + + @staticmethod + def _device_topup_renewal_available( + extra_hwid_devices: int, + extra_hwid_valid_until: Optional[Any], + subscription_end_date: Optional[Any], + ) -> bool: + if not isinstance(extra_hwid_valid_until, datetime) or not isinstance( + subscription_end_date, datetime + ): + return False + valid_until = ( + extra_hwid_valid_until + if extra_hwid_valid_until.tzinfo + else extra_hwid_valid_until.replace(tzinfo=timezone.utc) + ) + end_date = ( + subscription_end_date + if subscription_end_date.tzinfo + else subscription_end_date.replace(tzinfo=timezone.utc) + ) + return bool(int(extra_hwid_devices or 0) > 0 and valid_until < end_date) + async def _local_active_subscription_details_fallback( self, db_user: User, @@ -130,7 +161,9 @@ class SubscriptionLifecycleMixin: "base_hwid_device_limit": local_active_sub.hwid_device_limit, "extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0), "extra_hwid_devices_valid_until": None, + "extra_hwid_devices_valid_until_text": None, "extra_hwid_devices_next_valid_from": None, + "device_topup_renewal_available": False, "user_bot_username": db_user.username, "is_panel_data": False, "max_devices": self._effective_hwid_limit( @@ -1011,6 +1044,14 @@ class SubscriptionLifecycleMixin: if expected_hwid_limit is not None: hwid_limit = expected_hwid_limit + extra_hwid_valid_until = hwid_entitlement_summary.get("active_until") + extra_hwid_next_valid_from = hwid_entitlement_summary.get("next_valid_from") + device_topup_renewal_available = self._device_topup_renewal_available( + active_extra_hwid_devices, + extra_hwid_valid_until, + panel_end_date, + ) + return { "user_id": panel_user_data.get("uuid"), "panel_subscription_uuid": panel_user_data.get("subscriptionUuid") @@ -1071,8 +1112,12 @@ class SubscriptionLifecycleMixin: if local_active_sub else None, "extra_hwid_devices": active_extra_hwid_devices, - "extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"), - "extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get("next_valid_from"), + "extra_hwid_devices_valid_until": extra_hwid_valid_until, + "extra_hwid_devices_valid_until_text": self._display_datetime_text( + extra_hwid_valid_until + ), + "extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from, + "device_topup_renewal_available": device_topup_renewal_available, "user_bot_username": db_user.username, "is_panel_data": True, "max_devices": hwid_limit, diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 0a36276..7ac0e16 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -592,6 +592,78 @@ class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase): deactivate_all.assert_awaited_once_with(session, 42) update_user.assert_awaited_once_with(session, 42, {"panel_user_uuid": None}) + async def test_get_active_subscription_details_includes_device_topup_renewal_fields( + self, + ): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + active_until = datetime(2099, 1, 2, 3, 4, tzinfo=timezone.utc) + service.panel_service.get_user_by_uuid_lookup = AsyncMock( + return_value={ + "ok": True, + "user": { + "uuid": "panel-user", + "shortUuid": "short-uuid", + "status": "ACTIVE", + "expireAt": "2099-02-01T00:00:00Z", + "subscriptionUrl": "https://panel.example.test/sub/short-uuid", + "trafficLimitBytes": 1000, + "trafficLimitStrategy": "MONTH", + "userTraffic": { + "usedTrafficBytes": 100, + "lifetimeUsedTrafficBytes": 100, + }, + }, + } + ) + service.premium_access_for_tariff = AsyncMock( + return_value={"squad_uuids": [], "squad_labels": [], "node_labels": []} + ) + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + panel_user_uuid="panel-user", + username="alice", + language_code="en", + lifetime_used_traffic_bytes=100, + ) + local_sub = self._local_active_sub() + local_sub.tariff_key = "standard" + local_sub.extra_hwid_devices = 0 + local_sub.hwid_device_limit = 3 + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=local_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.get_hwid_device_entitlement_summary", + AsyncMock( + return_value={ + "active_devices": 1, + "active_until": active_until, + "next_valid_from": None, + } + ), + ), + ): + result = await service.get_active_subscription_details(session, user_id=42) + + self.assertTrue(result["device_topup_renewal_available"]) + self.assertEqual(result["extra_hwid_devices"], 1) + self.assertEqual(result["extra_hwid_devices_valid_until"], active_until) + self.assertEqual(result["extra_hwid_devices_valid_until_text"], "02.01.2099 03:04") + class SubscriptionDalPayloadTests(unittest.TestCase): def test_subscription_model_payload_drops_panel_only_keys(self): diff --git a/tests/test_user_bot_menu.py b/tests/test_user_bot_menu.py index 17789f0..0d43aad 100644 --- a/tests/test_user_bot_menu.py +++ b/tests/test_user_bot_menu.py @@ -5,7 +5,10 @@ from types import SimpleNamespace from bot.app.web import subscription_webapp from bot.handlers.user import referral -from bot.handlers.user.subscription.core import _with_subscription_purchase_description +from bot.handlers.user.subscription.core import ( + _format_premium_usage_limit, + _with_subscription_purchase_description, +) from bot.keyboards.inline.user_keyboards import ( get_bot_interface_inline_keyboard, get_connect_and_main_keyboard, @@ -207,6 +210,20 @@ class UserBotMenuTests(unittest.TestCase): "Choose traffic", ) + def test_premium_usage_limit_uses_byte_fields_when_display_fields_missing(self): + gib = 1024**3 + active = { + "premium_used": None, + "premium_limit": None, + "premium_used_bytes": int(1.25 * gib), + "premium_limit_bytes": 26 * gib, + } + + text = _format_premium_usage_limit(active) + + self.assertEqual(text, "1.25 GB из 26.00 GB") + self.assertNotIn("None", text) + def test_payment_navigation_context_keeps_bot_menu_source(self): settings = SimpleNamespace( payment_methods_order=[],