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