diff --git a/Makefile b/Makefile index 3210d9de..d33c0f28 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,17 @@ -.PHONY: up down reload test +.PHONY: up up-follow down reload reload-follow test up: - docker compose up -d + docker compose up -d --build + +up-follow: + docker compose up --build down: docker compose down reload: down up +reload-follow: down up-follow + test: pytest diff --git a/app/database/models.py b/app/database/models.py index c1f733aa..e6080017 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -565,20 +565,28 @@ class User(Base): def get_primary_promo_group(self): """Возвращает промогруппу с максимальным приоритетом.""" if not self.user_promo_groups: - return None + return getattr(self, "promo_group", None) - # Сортируем по приоритету группы (убывание), затем по ID группы - sorted_groups = sorted( - self.user_promo_groups, - key=lambda upg: (upg.promo_group.priority if upg.promo_group else 0, upg.promo_group_id), - reverse=True - ) + try: + # Сортируем по приоритету группы (убывание), затем по ID группы + # Используем getattr для защиты от ленивой загрузки + sorted_groups = sorted( + self.user_promo_groups, + key=lambda upg: ( + getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0, + upg.promo_group_id + ), + reverse=True + ) - if sorted_groups and sorted_groups[0].promo_group: - return sorted_groups[0].promo_group + if sorted_groups and sorted_groups[0].promo_group: + return sorted_groups[0].promo_group + except Exception: + # Если возникла ошибка (например, ленивая загрузка), fallback на старую связь + pass - # Fallback на старую связь если новая пустая - return self.promo_group + # Fallback на старую связь если новая пустая или возникла ошибка + return getattr(self, "promo_group", None) def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: primary_group = self.get_primary_promo_group() diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index c619913e..89bfcccf 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user( if user is None: return 0 - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() if promo_group is None: return 0 diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index bd04165e..9489f9ef 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -350,7 +350,7 @@ async def _build_subscription_period_prompt( if promo_offer_hint: lines.extend(["", promo_offer_hint]) - promo_text = _build_promo_group_discount_text( + promo_text = await _build_promo_group_discount_text( db_user, settings.get_available_subscription_periods(), texts=texts, diff --git a/app/handlers/subscription/promo.py b/app/handlers/subscription/promo.py index ade900ef..6fefd6ac 100644 --- a/app/handlers/subscription/promo.py +++ b/app/handlers/subscription/promo.py @@ -88,12 +88,12 @@ async def _get_promo_offer_hint( ) -> Optional[str]: return await build_promo_offer_hint(db, db_user, texts, percent) -def _build_promo_group_discount_text( +async def _build_promo_group_discount_text( db_user: User, periods: Optional[List[int]] = None, texts=None, ) -> str: - promo_group = getattr(db_user, "promo_group", None) + promo_group = db_user.get_primary_promo_group() if not promo_group: return "" diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 8350d095..8e339af1 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -446,7 +446,7 @@ async def show_trial_offer( trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, - traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, + traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB), devices_line=devices_line, server_name=trial_server_name ) @@ -979,10 +979,10 @@ async def handle_extend_subscription( total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period total_original_price = ( - base_price_original - + servers_price_per_month * months_in_period - + devices_price_per_month * months_in_period - + traffic_price_per_month * months_in_period + base_price + + total_servers_price + + total_devices_price + + total_traffic_price ) price = base_price + total_servers_price + total_devices_price + total_traffic_price @@ -1034,7 +1034,7 @@ async def handle_extend_subscription( f"{period_display} - {texts.format_price(final_price)}\n" ) - promo_discounts_text = _build_promo_group_discount_text( + promo_discounts_text = await _build_promo_group_discount_text( db_user, available_periods, texts=texts, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 98df0619..cba93833 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1979,7 +1979,7 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> discount_percent = ((original_price - final_price) * 100) // original_price button_text = ( f"📅 {period_display} - " - f"{texts.format_price(original_price)} " + f"{texts.format_price(original_price)} ➜ " f"{texts.format_price(final_price)} " f"(-{discount_percent}%)" ) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 8f8eaa80..e9fb1c0e 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1395,7 +1395,7 @@ "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic}{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Devices: {devices} pcs", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 1d6b72a7..4adaadd9 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1415,7 +1415,7 @@ "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic}{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Устройства: {devices} шт.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 3f757d6e..553e794f 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -221,7 +221,7 @@ class AdminNotificationService: ⏰ Параметры триала: 📅 Период: {settings.TRIAL_DURATION_DAYS} дней -📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ +📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)} 📱 Устройства: {trial_device_limit} 🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'} diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index c3a3a527..e4d3d5e5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -211,7 +211,7 @@ class CryptoBotPaymentMixin: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = ( diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 48b3dba3..c032f990 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -335,7 +335,7 @@ class HeleketPaymentMixin: topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" referrer_info = format_referrer_info(user) subscription = getattr(user, "subscription", None) - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index d7514a8b..36c63426 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -272,7 +272,7 @@ class MulenPayPaymentMixin: await db.refresh(user) - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = ( diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 927bc443..501dbe2c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -362,7 +362,7 @@ class Pal24PaymentMixin: user.balance_kopeks += payment.amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index da2f68ed..9d7e4e33 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -430,7 +430,7 @@ class TelegramStarsMixin: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index c0b7e339..5bff005b 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -464,7 +464,7 @@ class WataPaymentMixin: await db.commit() await db.refresh(user) - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index dced5d42..aaaa37e2 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -50,7 +50,7 @@ def _resolve_addon_discount_percent( *, period_days: Optional[int] = None, ) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) + group = promo_group or (user.get_primary_promo_group() if user else None) if group is not None and not getattr(group, "apply_discounts_to_addons", True): return 0 diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 7e9108bb..c21906d7 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -131,7 +131,7 @@ class TributeService: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 2a874a71..01801821 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -343,7 +343,7 @@ def format_period_option_label( if original_price > 0 and discount_percent > 0 and original_price > price: return ( f"{label} - " - f"{settings.format_price(original_price)} " + f"{settings.format_price(original_price)} ➜ " f"{settings.format_price(price)} " f"(-{discount_percent}%)" ) diff --git a/tests/services/test_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py index ba4c757c..0eddaba8 100644 --- a/tests/services/test_payment_service_mulenpay.py +++ b/tests/services/test_payment_service_mulenpay.py @@ -203,6 +203,10 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( self.language = "ru" self.promo_group = None self.subscription = None + self.user_promo_groups = [] + + def get_primary_promo_group(self): + return self.promo_group dummy_user = DummyUser() diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 0381f2cf..61d66f5e 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -208,6 +208,7 @@ async def test_process_mulenpay_callback_success( referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -302,6 +303,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user_crypto(db, user_id): return user @@ -431,6 +433,7 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user if user_id == user.id else None @@ -538,6 +541,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -666,6 +670,7 @@ async def test_process_yookassa_webhook_restores_missing_payment( referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -816,6 +821,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -982,6 +988,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user diff --git a/tests/services/test_remnawave_service_sync.py b/tests/services/test_remnawave_service_sync.py index 1acb9804..24904455 100644 --- a/tests/services/test_remnawave_service_sync.py +++ b/tests/services/test_remnawave_service_sync.py @@ -81,7 +81,7 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch): db.rollback = rollback_mock - monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock) + monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) monkeypatch.setattr( "app.services.remnawave_service.get_user_by_telegram_id", get_user_mock, @@ -105,7 +105,7 @@ async def test_get_or_create_user_creates_new(monkeypatch): create_user_mock = AsyncMock(return_value=new_user) - monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock) + monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) user, created = await service._get_or_create_bot_user_from_panel(db, panel_user) diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index 1137f1e9..8ab72429 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -34,6 +34,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): user.balance_kopeks = 200_000 user.language = "ru" user.subscription = None + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "period_days": 30, @@ -202,6 +203,7 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): user.balance_kopeks = 200_000 user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", @@ -320,6 +322,7 @@ async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch user.balance_kopeks = 60_000 user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", @@ -376,6 +379,7 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa user.balance_kopeks = 200_000 # Достаточно денег user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", @@ -486,6 +490,7 @@ async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): user.balance_kopeks = 200_000 # Достаточно денег user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", @@ -572,6 +577,7 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): user.balance_kopeks = 200_000 user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py index e4bfc5a8..1750a5a7 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -31,6 +31,9 @@ def mock_user(): user.balance_kopeks = 10000 user.subscription = None user.has_had_paid_subscription = False + user.promo_group_id = None + user.get_primary_promo_group = MagicMock(return_value=None) + user.get_promo_discount = MagicMock(return_value=0) return user @pytest.fixture