mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Handle repeated return-to-cart callbacks without redundant edits
This commit is contained in:
@@ -58,6 +58,39 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
|
||||
if markup is None:
|
||||
return None
|
||||
|
||||
model_dump = getattr(markup, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
try:
|
||||
return model_dump(exclude_none=True)
|
||||
except TypeError:
|
||||
return model_dump()
|
||||
|
||||
to_python = getattr(markup, "to_python", None)
|
||||
if callable(to_python):
|
||||
return to_python()
|
||||
|
||||
return markup
|
||||
|
||||
|
||||
def _message_needs_update(
|
||||
message: types.Message,
|
||||
new_text: str,
|
||||
new_markup: Optional[InlineKeyboardMarkup],
|
||||
) -> bool:
|
||||
current_text = getattr(message, "text", None)
|
||||
|
||||
if current_text != new_text:
|
||||
return True
|
||||
|
||||
current_markup = getattr(message, "reply_markup", None)
|
||||
|
||||
return _serialize_markup(current_markup) != _serialize_markup(new_markup)
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
@@ -800,16 +833,24 @@ async def return_to_saved_cart(
|
||||
|
||||
if db_user.balance_kopeks < total_price:
|
||||
missing_amount = total_price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
insufficient_keyboard = get_insufficient_balance_keyboard_with_cart(
|
||||
db_user.language,
|
||||
missing_amount,
|
||||
)
|
||||
insufficient_text = (
|
||||
f"❌ Все еще недостаточно средств\n\n"
|
||||
f"Требуется: {texts.format_price(total_price)}\n"
|
||||
f"У вас: {texts.format_price(db_user.balance_kopeks)}\n"
|
||||
f"Не хватает: {texts.format_price(missing_amount)}",
|
||||
reply_markup=get_insufficient_balance_keyboard_with_cart(
|
||||
db_user.language,
|
||||
missing_amount,
|
||||
)
|
||||
f"Не хватает: {texts.format_price(missing_amount)}"
|
||||
)
|
||||
|
||||
if _message_needs_update(callback.message, insufficient_text, insufficient_keyboard):
|
||||
await callback.message.edit_text(
|
||||
insufficient_text,
|
||||
reply_markup=insufficient_keyboard,
|
||||
)
|
||||
else:
|
||||
await callback.answer("ℹ️ Пополните баланс, чтобы завершить оформление.")
|
||||
return
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
@@ -856,11 +897,14 @@ async def return_to_saved_cart(
|
||||
await state.set_data(prepared_cart_data)
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
confirm_keyboard = get_subscription_confirm_keyboard_with_cart(db_user.language)
|
||||
|
||||
if _message_needs_update(callback.message, summary_text, confirm_keyboard):
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=confirm_keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Корзина восстановлена!")
|
||||
|
||||
|
||||
@@ -356,10 +356,13 @@ def get_main_menu_keyboard(
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
resume_callback = (
|
||||
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
|
||||
)
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -671,7 +674,7 @@ def get_insufficient_balance_keyboard(
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
@@ -803,7 +806,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,14 +57,32 @@ class PaymentCommonMixin:
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
if user:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
except Exception as cart_error:
|
||||
logger.warning(
|
||||
"Не удалось проверить наличие сохраненной корзины у пользователя %s: %s",
|
||||
user.id,
|
||||
cart_error,
|
||||
)
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart:
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
])
|
||||
else:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
# Стандартные кнопки быстрого доступа к балансу и главному меню.
|
||||
keyboard_rows.append([
|
||||
|
||||
@@ -332,7 +332,7 @@ class CryptoBotPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
|
||||
@@ -366,7 +366,7 @@ class MulenPayPaymentMixin:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
|
||||
@@ -473,7 +473,7 @@ class Pal24PaymentMixin:
|
||||
"BALANCE_TOPUP_CART_BUTTON",
|
||||
"🛒 Продолжить оформление",
|
||||
),
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -584,7 +584,7 @@ class TelegramStarsMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -561,7 +561,7 @@ class WataPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -534,7 +534,7 @@ class YooKassaPaymentMixin:
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -882,7 +882,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
saved_cart_message = bot.sent_messages[-1]
|
||||
reply_markup = saved_cart_message["kwargs"].get("reply_markup")
|
||||
assert reply_markup is not None
|
||||
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "subscription_resume_checkout"
|
||||
assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "return_to_saved_cart"
|
||||
assert admin_calls
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, User as TgUser, Message
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
User as TgUser,
|
||||
Message,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart
|
||||
from app.handlers.subscription.autopay import handle_subscription_cancel
|
||||
@@ -58,12 +64,14 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state,
|
||||
|
||||
# Подготовим моки
|
||||
mock_cart_service.save_user_cart = AsyncMock(return_value=True)
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]]
|
||||
)
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.format_price = lambda x: f"{x/100:.0f} ₽"
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
missing_amount = 40000 # 50000 - 10000 = 40000
|
||||
@@ -119,12 +127,14 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
|
||||
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
|
||||
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
|
||||
mock_format_period.return_value = "30 дней"
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]]
|
||||
)
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.format_price = lambda x: f"{x/100:.0f} ₽"
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
# Увеличиваем баланс пользователя, чтобы его хватило
|
||||
@@ -143,6 +153,77 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_skips_edit_when_message_matches(
|
||||
mock_callback_query,
|
||||
mock_state,
|
||||
mock_user,
|
||||
mock_db,
|
||||
):
|
||||
cart_data = {
|
||||
'period_days': 60,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 40,
|
||||
'total_price': 44000,
|
||||
'saved_cart': True,
|
||||
'user_id': mock_user.id,
|
||||
}
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]]
|
||||
)
|
||||
existing_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]]
|
||||
)
|
||||
|
||||
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
|
||||
patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \
|
||||
patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \
|
||||
patch('app.localization.texts.get_texts') as mock_get_texts, \
|
||||
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \
|
||||
patch('app.handlers.subscription.purchase.settings') as mock_settings:
|
||||
|
||||
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
|
||||
mock_cart_service.save_user_cart = AsyncMock()
|
||||
mock_get_countries.return_value = [
|
||||
{'uuid': 'ru', 'name': 'Russia'},
|
||||
{'uuid': 'us', 'name': 'USA'},
|
||||
]
|
||||
mock_format_period.return_value = "60 дней"
|
||||
mock_keyboard_func.return_value = confirm_keyboard
|
||||
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100:.0f} ₽"
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
mock_settings.is_devices_selection_enabled.return_value = True
|
||||
mock_settings.is_traffic_fixed.return_value = False
|
||||
|
||||
mock_user.balance_kopeks = 50000
|
||||
|
||||
summary_text = (
|
||||
"🛒 Восстановленная корзина\n\n"
|
||||
"📅 Период: 60 дней\n"
|
||||
"📊 Трафик: 40 ГБ\n"
|
||||
"🌍 Страны: Russia, USA\n"
|
||||
"📱 Устройства: 3\n\n"
|
||||
"💎 Общая стоимость: 440 ₽\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
|
||||
mock_callback_query.message.text = summary_text
|
||||
mock_callback_query.message.reply_markup = existing_keyboard
|
||||
|
||||
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
mock_callback_query.message.edit_text.assert_not_called()
|
||||
mock_callback_query.answer.assert_called_once_with("✅ Корзина восстановлена!")
|
||||
mock_state.set_data.assert_called_once_with(cart_data)
|
||||
mock_state.set_state.assert_called_once()
|
||||
mock_cart_service.save_user_cart.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
|
||||
mock_callback_query,
|
||||
@@ -182,11 +263,13 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled(
|
||||
mock_cart_service.save_user_cart = AsyncMock()
|
||||
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
|
||||
mock_format_period.return_value = "30 дней"
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]]
|
||||
)
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.format_price = lambda x: f"{x/100:.0f} ₽"
|
||||
mock_texts.t = lambda key, default=None: default or ""
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
@@ -237,12 +320,14 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock
|
||||
|
||||
# Подготовим моки
|
||||
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="Пополнить", callback_data="topup")]]
|
||||
)
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.format_price = lambda x: f"{x/100:.0f} ₽"
|
||||
mock_texts.t = lambda key, default: default
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user