Handle repeated return-to-cart callbacks without redundant edits

This commit is contained in:
Egor
2025-11-02 06:13:16 +03:00
parent 0634d01336
commit 1a955d920a
11 changed files with 184 additions and 33 deletions

View File

@@ -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("✅ Корзина восстановлена!")

View File

@@ -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"
)
])

View File

@@ -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([

View File

@@ -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="💰 Мой баланс",

View File

@@ -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="💰 Мой баланс",

View File

@@ -473,7 +473,7 @@ class Pal24PaymentMixin:
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
],
[

View File

@@ -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",
)
],
[

View File

@@ -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",
)
],
[

View File

@@ -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",
)
],
[

View File

@@ -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

View File

@@ -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