Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса.
This commit is contained in:
@@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import (
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
@@ -162,6 +163,13 @@ async def show_main_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
|
||||
has_saved_cart = False
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
@@ -190,6 +198,7 @@ async def show_main_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -893,6 +902,13 @@ async def handle_back_to_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
|
||||
has_saved_cart = False
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
@@ -921,6 +937,7 @@ async def handle_back_to_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
|
||||
@@ -46,6 +46,7 @@ from app.keyboards.inline import (
|
||||
get_subscription_confirm_keyboard_with_cart,
|
||||
get_insufficient_balance_keyboard_with_cart
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -563,13 +564,16 @@ async def save_cart_and_redirect_to_topup(
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
|
||||
await state.set_state(SubscriptionStates.cart_saved_for_topup)
|
||||
await state.update_data({
|
||||
# Сохраняем данные корзины в Redis
|
||||
cart_data = {
|
||||
**data,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_amount,
|
||||
'return_to_cart': True
|
||||
})
|
||||
'return_to_cart': True,
|
||||
'user_id': db_user.id
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 Недостаточно средств для оформления подписки\n\n"
|
||||
@@ -591,14 +595,15 @@ async def return_to_saved_cart(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not data.get('saved_cart'):
|
||||
# Получаем данные корзины из Redis
|
||||
cart_data = await user_cart_service.get_user_cart(db_user.id)
|
||||
|
||||
if not cart_data:
|
||||
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
total_price = data.get('total_price', 0)
|
||||
texts = get_texts(db_user.language)
|
||||
total_price = cart_data.get('total_price', 0)
|
||||
|
||||
if db_user.balance_kopeks < total_price:
|
||||
missing_amount = total_price - db_user.balance_kopeks
|
||||
@@ -617,35 +622,38 @@ async def return_to_saved_cart(
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
selected_countries_names = []
|
||||
|
||||
months_in_period = calculate_months_from_days(data['period_days'])
|
||||
period_display = format_period_description(data['period_days'], db_user.language)
|
||||
months_in_period = calculate_months_from_days(cart_data['period_days'])
|
||||
period_display = format_period_description(cart_data['period_days'], db_user.language)
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
if country['uuid'] in cart_data['countries']:
|
||||
selected_countries_names.append(country['name'])
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
|
||||
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
|
||||
else:
|
||||
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
|
||||
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
|
||||
|
||||
summary_text = (
|
||||
"🛒 Восстановленная корзина\n\n"
|
||||
f"📅 Период: {period_display}\n"
|
||||
f"📊 Трафик: {traffic_display}\n"
|
||||
f"🌍 Страны: {', '.join(selected_countries_names)}\n"
|
||||
f"📱 Устройства: {data['devices']}\n\n"
|
||||
f"📱 Устройства: {cart_data['devices']}\n\n"
|
||||
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
|
||||
# Устанавливаем данные в FSM для продолжения процесса
|
||||
await state.set_data(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"
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
await callback.answer("✅ Корзина восстановлена!")
|
||||
|
||||
async def handle_extend_subscription(
|
||||
@@ -964,11 +972,24 @@ async def confirm_extend_subscription(
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Подготовим данные для сохранения в корзину
|
||||
cart_data = {
|
||||
'period_days': days,
|
||||
'total_price': price,
|
||||
'user_id': db_user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True # Указываем, что есть сохраненная корзина
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -1481,12 +1502,24 @@ async def confirm_purchase(
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Сохраняем данные корзины в Redis перед переходом к пополнению
|
||||
cart_data = {
|
||||
**data,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing_kopeks,
|
||||
'return_to_cart': True,
|
||||
'user_id': db_user.id
|
||||
}
|
||||
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
resume_callback=resume_callback,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True # Указываем, что есть сохраненная корзина
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -1937,7 +1970,9 @@ async def clear_saved_cart(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Очищаем как FSM, так и Redis
|
||||
await state.clear()
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
+29
-5
@@ -231,6 +231,7 @@ def get_main_menu_keyboard(
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
@@ -343,7 +344,7 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
|
||||
if show_resume_checkout:
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
@@ -630,6 +631,7 @@ def get_insufficient_balance_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
resume_callback: str | None = None,
|
||||
amount_kopeks: int | None = None,
|
||||
has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
texts = get_texts(language)
|
||||
@@ -650,14 +652,23 @@ def get_insufficient_balance_keyboard(
|
||||
)
|
||||
back_row_index = len(keyboard.inline_keyboard) - 1
|
||||
|
||||
if resume_callback:
|
||||
# Если есть сохраненная корзина, добавляем кнопку возврата к оформлению
|
||||
if has_saved_cart:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
elif resume_callback:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
]
|
||||
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
|
||||
@@ -763,6 +774,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
|
||||
|
||||
# Добавляем кнопку "Очистить корзину"
|
||||
@@ -773,9 +785,18 @@ def get_payment_methods_keyboard_with_cart(
|
||||
)
|
||||
])
|
||||
|
||||
# Добавляем кнопку возврата к оформлению подписки
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
)
|
||||
])
|
||||
|
||||
return keyboard
|
||||
|
||||
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить покупку",
|
||||
@@ -786,8 +807,8 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK
|
||||
callback_data="clear_saved_cart"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="back_to_menu"
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_config_back" # Изменили на возврат к настройке
|
||||
)]
|
||||
])
|
||||
|
||||
@@ -795,11 +816,14 @@ def get_insufficient_balance_keyboard_with_cart(
|
||||
language: str = "ru",
|
||||
amount_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
# Используем обновленную версию с флагом has_saved_cart=True
|
||||
keyboard = get_insufficient_balance_keyboard(
|
||||
language,
|
||||
amount_kopeks=amount_kopeks,
|
||||
has_saved_cart=True,
|
||||
)
|
||||
|
||||
# Добавляем кнопку очистки корзины в начало
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
|
||||
@@ -408,6 +408,16 @@ class AdminNotificationService:
|
||||
|
||||
return refreshed_user, refreshed_transaction, subscription, promo_group
|
||||
|
||||
def _is_lazy_loading_error(self, error: Exception) -> bool:
|
||||
message = str(error).lower()
|
||||
return (
|
||||
isinstance(error, MissingGreenlet)
|
||||
or "greenlet_spawn" in message
|
||||
or "await_only" in message
|
||||
or "missinggreenlet" in message
|
||||
)
|
||||
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
user: User,
|
||||
@@ -423,7 +433,10 @@ class AdminNotificationService:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся создать сообщение уведомления")
|
||||
message = self._build_balance_topup_message(
|
||||
user,
|
||||
transaction,
|
||||
@@ -433,21 +446,49 @@ class AdminNotificationService:
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except MissingGreenlet as missing_greenlet:
|
||||
if db is None:
|
||||
logger.info("Сообщение уведомления создано успешно")
|
||||
except Exception as error:
|
||||
logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}")
|
||||
if not self._is_lazy_loading_error(error):
|
||||
logger.error(
|
||||
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
|
||||
missing_greenlet,
|
||||
"Ошибка подготовки уведомления о пополнении: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if db is None:
|
||||
logger.error(
|
||||
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся перезагрузить данные для уведомления")
|
||||
(
|
||||
user,
|
||||
transaction,
|
||||
subscription,
|
||||
promo_group,
|
||||
) = await self._reload_topup_notification_entities(db, user, transaction)
|
||||
logger.info("Данные успешно перезагружены")
|
||||
except Exception as reload_error:
|
||||
logger.error(
|
||||
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
|
||||
reload_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info("Пытаемся создать сообщение после перезагрузки данных")
|
||||
message = self._build_balance_topup_message(
|
||||
user,
|
||||
transaction,
|
||||
@@ -457,24 +498,22 @@ class AdminNotificationService:
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as reload_error:
|
||||
logger.info("Сообщение успешно создано после перезагрузки данных")
|
||||
except Exception as rebuild_error:
|
||||
logger.error(
|
||||
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
|
||||
reload_error,
|
||||
"Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s",
|
||||
rebuild_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка подготовки уведомления о пополнении: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._send_message(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о пополнении: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_subscription_extension_notification(
|
||||
|
||||
@@ -290,6 +290,31 @@ class CryptoBotPaymentMixin:
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление о возможности вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n"
|
||||
"Нажмите \"Вернуться к оформлению подписки\" в главном меню, "
|
||||
"чтобы продолжить с теми же параметрами."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=cart_message
|
||||
)
|
||||
logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
|
||||
@@ -286,6 +286,31 @@ class MulenPayPaymentMixin:
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление о возможности вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n"
|
||||
"Нажмите \"Вернуться к оформлению подписки\" в главном меню, "
|
||||
"чтобы продолжить с теми же параметрами."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=cart_message
|
||||
)
|
||||
logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
payment.uuid,
|
||||
|
||||
@@ -353,6 +353,31 @@ class Pal24PaymentMixin:
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление о возможности вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n"
|
||||
"Нажмите \"Вернуться к оформлению подписки\" в главном меню, "
|
||||
"чтобы продолжить с теми же параметрами."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=cart_message
|
||||
)
|
||||
logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
|
||||
@@ -206,6 +206,7 @@ class TelegramStarsMixin:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
||||
error,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
@@ -236,6 +237,31 @@ class TelegramStarsMixin:
|
||||
error,
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление о возможности вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n"
|
||||
"Нажмите \"Вернуться к оформлению подписки\" в главном меню, "
|
||||
"чтобы продолжить с теми же параметрами."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=cart_message
|
||||
)
|
||||
logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
|
||||
@@ -268,6 +268,7 @@ class YooKassaPaymentMixin:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Отправляем уведомления админам
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
@@ -285,26 +286,70 @@ class YooKassaPaymentMixin:
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
logger.info("Уведомление админам о пополнении отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Отправляем уведомление пользователю
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks,
|
||||
user=user,
|
||||
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
|
||||
db=db,
|
||||
payment_method_title="Банковская карта (YooKassa)",
|
||||
)
|
||||
logger.info("Уведомление пользователю о платеже отправлено успешно")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о платеже: %s", error
|
||||
"Ошибка отправки уведомления о платеже: %s",
|
||||
error,
|
||||
exc_info=True # Добавляем полный стек вызовов для отладки
|
||||
)
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
|
||||
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление о возможности вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER",
|
||||
"💰 Баланс пополнен! У вас есть неоформленный заказ.\n\n"
|
||||
"Нажмите \"Вернуться к оформлению подписки\" в главном меню, "
|
||||
"чтобы продолжить с теми же параметрами."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=cart_message
|
||||
)
|
||||
logger.info(f"Отправлено уведомление о сохраненной корзине пользователю {user.id}")
|
||||
else:
|
||||
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
payment.user_id,
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s₽",
|
||||
payment.yookassa_payment_id,
|
||||
|
||||
@@ -167,6 +167,7 @@ class TributeService:
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=session,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import timedelta
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserCartService:
|
||||
"""
|
||||
Сервис для работы с корзиной пользователя через Redis
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = None
|
||||
self._setup_redis()
|
||||
|
||||
def _setup_redis(self):
|
||||
"""Инициализация Redis клиента"""
|
||||
try:
|
||||
self.redis_client = redis.from_url(settings.REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка подключения к Redis: {e}")
|
||||
raise
|
||||
|
||||
async def save_user_cart(self, user_id: int, cart_data: Dict[str, Any], ttl: int = 3600) -> bool:
|
||||
"""
|
||||
Сохранить корзину пользователя в Redis
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
cart_data: Данные корзины (параметры подписки)
|
||||
ttl: Время жизни ключа в секундах (по умолчанию 1 час)
|
||||
|
||||
Returns:
|
||||
bool: Успешность сохранения
|
||||
"""
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
json_data = json.dumps(cart_data, ensure_ascii=False)
|
||||
await self.redis_client.setex(key, ttl, json_data)
|
||||
logger.info(f"Корзина пользователя {user_id} сохранена в Redis")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получить корзину пользователя из Redis
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
dict: Данные корзины или None
|
||||
"""
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
json_data = await self.redis_client.get(key)
|
||||
if json_data:
|
||||
cart_data = json.loads(json_data)
|
||||
logger.info(f"Корзина пользователя {user_id} загружена из Redis")
|
||||
return cart_data
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_user_cart(self, user_id: int) -> bool:
|
||||
"""
|
||||
Удалить корзину пользователя из Redis
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
bool: Успешность удаления
|
||||
"""
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
result = await self.redis_client.delete(key)
|
||||
if result:
|
||||
logger.info(f"Корзина пользователя {user_id} удалена из Redis")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def has_user_cart(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверить наличие корзины у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
bool: Наличие корзины
|
||||
"""
|
||||
try:
|
||||
key = f"user_cart:{user_id}"
|
||||
exists = await self.redis_client.exists(key)
|
||||
return bool(exists)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
user_cart_service = UserCartService()
|
||||
@@ -0,0 +1,75 @@
|
||||
# Сквозная корзина (Persistent Cart) для Remnawave Bedolaga Telegram Bot
|
||||
|
||||
## Общее описание
|
||||
|
||||
Система сквозной корзины позволяет пользователям продолжить оформление подписки после пополнения баланса, не теряя уже выбранные параметры (период, трафик, серверы, устройства и т.д.).
|
||||
|
||||
## Архитектура
|
||||
|
||||
### 1. Сервис корзины (UserCartService)
|
||||
|
||||
Расположение: `app/services/user_cart_service.py`
|
||||
|
||||
Использует Redis для хранения данных корзины между сессиями пользователя.
|
||||
|
||||
#### Основные методы:
|
||||
- `save_user_cart(user_id, cart_data, ttl)` - сохраняет корзину пользователя
|
||||
- `get_user_cart(user_id)` - возвращает данные корзины пользователя
|
||||
- `delete_user_cart(user_id)` - удаляет корзину пользователя
|
||||
- `has_user_cart(user_id)` - проверяет наличие корзины у пользователя
|
||||
|
||||
### 2. Обновленные обработчики подписки
|
||||
|
||||
Расположение: `app/handlers/subscription/purchase.py`
|
||||
|
||||
#### Основные функции:
|
||||
- `save_cart_and_redirect_to_topup` - сохраняет текущую корзину в Redis при недостатке средств и перенаправляет к пополнению
|
||||
- `return_to_saved_cart` - восстанавливает параметры подписки из Redis и продолжает процесс оформления
|
||||
- `clear_saved_cart` - очищает сохраненную корзину
|
||||
|
||||
### 3. Обновленные клавиатуры
|
||||
|
||||
Расположение: `app/keyboards/inline.py`
|
||||
|
||||
#### Основные изменения:
|
||||
- `get_insufficient_balance_keyboard` - добавлена поддержка флага `has_saved_cart` для отображения кнопки возврата к оформлению
|
||||
- `get_insufficient_balance_keyboard_with_cart` - обновлена для использования флага `has_saved_cart`
|
||||
- `get_main_menu_keyboard` - добавлен параметр `has_saved_cart` для отображения кнопки возврата к оформлению
|
||||
|
||||
### 4. Интеграция с главным меню
|
||||
|
||||
Расположение: `app/handlers/menu.py`
|
||||
|
||||
Функция `show_main_menu` теперь проверяет наличие сохраненной корзины и отображает соответствующую кнопку.
|
||||
|
||||
## Использование
|
||||
|
||||
### Сохранение корзины
|
||||
|
||||
Когда пользователь не может завершить покупку из-за недостатка средств, его параметры автоматически сохраняются в Redis с TTL 1 час.
|
||||
|
||||
### Восстановление корзины
|
||||
|
||||
При пополнении баланса пользователь может вернуться к оформлению подписки, нажав на кнопку "Вернуться к оформлению подписки" или через главное меню, если у него есть сохраненная корзина.
|
||||
|
||||
### Очистка корзины
|
||||
|
||||
Корзина автоматически очищается после успешного оформления подписки или по запросу пользователя.
|
||||
|
||||
## Тестирование
|
||||
|
||||
Тесты расположены в:
|
||||
- `tests/test_user_cart_service.py` - модульные тесты сервиса корзины
|
||||
- `tests/test_subscription_cart_integration.py` - интеграционные тесты
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Данные корзины хранятся в Redis с ограниченным временем жизни (TTL)
|
||||
- Используется идентификатор пользователя для изоляции данных
|
||||
- Корзина автоматически очищается после успешной покупки
|
||||
|
||||
## Масштабируемость
|
||||
|
||||
- Использование Redis позволяет системе масштабироваться на несколько инстансов бота
|
||||
- TTL автоматически очищает старые данные
|
||||
- Нагрузка на Redis минимальна благодаря короткому времени хранения данных
|
||||
@@ -0,0 +1,210 @@
|
||||
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 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.database.models import User, Subscription
|
||||
|
||||
@pytest.fixture
|
||||
def mock_callback_query():
|
||||
callback = AsyncMock(spec=CallbackQuery)
|
||||
callback.message = AsyncMock(spec=Message)
|
||||
callback.message.edit_text = AsyncMock()
|
||||
callback.answer = AsyncMock()
|
||||
callback.data = "subscription_confirm"
|
||||
return callback
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
user = AsyncMock(spec=User)
|
||||
user.id = 12345
|
||||
user.telegram_id = 12345
|
||||
user.language = "ru"
|
||||
user.balance_kopeks = 10000
|
||||
user.subscription = None
|
||||
user.has_had_paid_subscription = False
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock(spec=AsyncSession)
|
||||
return db
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
state = AsyncMock(spec=FSMContext)
|
||||
state.get_data = AsyncMock(return_value={
|
||||
'period_days': 30,
|
||||
'countries': ['ru'],
|
||||
'devices': 2,
|
||||
'traffic_gb': 10,
|
||||
'total_price': 50000
|
||||
})
|
||||
state.set_data = AsyncMock()
|
||||
state.update_data = AsyncMock()
|
||||
state.set_state = AsyncMock()
|
||||
state.clear = AsyncMock()
|
||||
return state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест сохранения корзины и перенаправления к пополнению"""
|
||||
# Мокаем все зависимости
|
||||
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
|
||||
patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, \
|
||||
patch('app.localization.texts.get_texts') as mock_get_texts:
|
||||
|
||||
# Подготовим моки
|
||||
mock_cart_service.save_user_cart = AsyncMock(return_value=True)
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
missing_amount = 40000 # 50000 - 10000 = 40000
|
||||
|
||||
# Вызываем функцию
|
||||
await save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, missing_amount)
|
||||
|
||||
# Проверяем, что данные были сохранены в корзину
|
||||
mock_cart_service.save_user_cart.assert_called_once()
|
||||
args, kwargs = mock_cart_service.save_user_cart.call_args
|
||||
saved_user_id, saved_cart_data = args
|
||||
|
||||
assert saved_user_id == mock_user.id
|
||||
assert saved_cart_data['period_days'] == 30
|
||||
assert saved_cart_data['countries'] == ['ru']
|
||||
assert saved_cart_data['devices'] == 2
|
||||
assert saved_cart_data['traffic_gb'] == 10
|
||||
assert saved_cart_data['total_price'] == 50000
|
||||
assert saved_cart_data['saved_cart'] is True
|
||||
assert saved_cart_data['missing_amount'] == missing_amount
|
||||
assert saved_cart_data['return_to_cart'] is True
|
||||
assert saved_cart_data['user_id'] == mock_user.id
|
||||
|
||||
# Проверяем, что сообщение было отредактировано
|
||||
mock_callback_query.message.edit_text.assert_called_once()
|
||||
|
||||
# В этой функции нет вызова callback.answer()
|
||||
# mock_callback_query.answer не должен быть вызван
|
||||
mock_callback_query.answer.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест возврата к сохраненной корзине с достаточным балансом"""
|
||||
# Подготовим данные корзины
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 20,
|
||||
'total_price': 30000, # Меньше, чем баланс пользователя (50000)
|
||||
'saved_cart': True,
|
||||
'user_id': mock_user.id
|
||||
}
|
||||
|
||||
# Мокаем все зависимости
|
||||
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:
|
||||
|
||||
# Подготовим моки
|
||||
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_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
# Увеличиваем баланс пользователя, чтобы его хватило
|
||||
mock_user.balance_kopeks = 50000
|
||||
|
||||
# Вызываем функцию
|
||||
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
# Проверяем, что данные были загружены из корзины и установлены в FSM
|
||||
mock_state.set_data.assert_called_once_with(cart_data)
|
||||
|
||||
# Проверяем, что сообщение было отредактировано
|
||||
mock_callback_query.message.edit_text.assert_called_once()
|
||||
|
||||
# В успешном сценарии вызывается callback.answer()
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
|
||||
# Подготовим данные корзины
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 20,
|
||||
'total_price': 50000, # Больше, чем баланс пользователя (10000)
|
||||
'saved_cart': True,
|
||||
'user_id': mock_user.id
|
||||
}
|
||||
|
||||
# Мокаем все зависимости
|
||||
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
|
||||
patch('app.localization.texts.get_texts') as mock_get_texts, \
|
||||
patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func:
|
||||
|
||||
# Подготовим моки
|
||||
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
# Подготовим тексты
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.t = lambda key, default: default
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
# Баланс пользователя меньше стоимости подписки
|
||||
mock_user.balance_kopeks = 10000
|
||||
|
||||
# Вызываем функцию
|
||||
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
# Проверяем, что FSM не был изменен (данные не установлены)
|
||||
mock_state.set_data.assert_not_called()
|
||||
|
||||
# Проверяем, что сообщение было отредактировано с сообщением о недостатке средств
|
||||
mock_callback_query.message.edit_text.assert_called_once()
|
||||
|
||||
# В этой функции в сценарии недостатка средств вызова callback.answer() не происходит
|
||||
# (ответ отправляется через return до вызова callback.answer())
|
||||
mock_callback_query.answer.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест очистки сохраненной корзины"""
|
||||
# Мокаем все зависимости
|
||||
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
|
||||
patch('app.handlers.menu.show_main_menu') as mock_show_main_menu:
|
||||
|
||||
mock_cart_service.delete_user_cart = AsyncMock(return_value=True)
|
||||
mock_show_main_menu.return_value = AsyncMock()
|
||||
|
||||
# Вызываем функцию
|
||||
await clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
# Проверяем, что корзина удалена из сервиса
|
||||
mock_cart_service.delete_user_cart.assert_called_once_with(mock_user.id)
|
||||
|
||||
# Проверяем, что FSM очищен
|
||||
mock_state.clear.assert_called_once()
|
||||
|
||||
# Проверяем, что вызван answer
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
@@ -0,0 +1,145 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.user_cart_service import UserCartService
|
||||
from app.config import settings
|
||||
|
||||
# Мок для Redis клиента
|
||||
class MockRedis:
|
||||
def __init__(self):
|
||||
self.storage = {}
|
||||
|
||||
async def setex(self, key, ttl, value):
|
||||
self.storage[key] = value
|
||||
return True
|
||||
|
||||
async def get(self, key):
|
||||
return self.storage.get(key)
|
||||
|
||||
async def delete(self, key):
|
||||
if key in self.storage:
|
||||
del self.storage[key]
|
||||
return 1
|
||||
return 0
|
||||
|
||||
async def exists(self, key):
|
||||
return 1 if key in self.storage else 0
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
return MockRedis()
|
||||
|
||||
@pytest.fixture
|
||||
def user_cart_service(mock_redis):
|
||||
service = UserCartService()
|
||||
service.redis_client = mock_redis
|
||||
return service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест сохранения корзины пользователя"""
|
||||
user_id = 12345
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 10,
|
||||
'total_price': 50000
|
||||
}
|
||||
|
||||
result = await user_cart_service.save_user_cart(user_id, cart_data)
|
||||
|
||||
assert result is True
|
||||
assert f"user_cart:{user_id}" in mock_redis.storage
|
||||
assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест получения корзины пользователя"""
|
||||
user_id = 12345
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 10,
|
||||
'total_price': 50000
|
||||
}
|
||||
|
||||
# Сохраняем данные
|
||||
await user_cart_service.save_user_cart(user_id, cart_data)
|
||||
|
||||
# Получаем данные
|
||||
result = await user_cart_service.get_user_cart(user_id)
|
||||
|
||||
assert result == cart_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_cart_not_found(user_cart_service):
|
||||
"""Тест получения несуществующей корзины пользователя"""
|
||||
user_id = 99999
|
||||
|
||||
result = await user_cart_service.get_user_cart(user_id)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест удаления корзины пользователя"""
|
||||
user_id = 12345
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 10,
|
||||
'total_price': 50000
|
||||
}
|
||||
|
||||
# Сохраняем данные
|
||||
await user_cart_service.save_user_cart(user_id, cart_data)
|
||||
assert f"user_cart:{user_id}" in mock_redis.storage
|
||||
|
||||
# Удаляем данные
|
||||
result = await user_cart_service.delete_user_cart(user_id)
|
||||
|
||||
assert result is True
|
||||
assert f"user_cart:{user_id}" not in mock_redis.storage
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_cart_not_found(user_cart_service):
|
||||
"""Тест удаления несуществующей корзины пользователя"""
|
||||
user_id = 99999
|
||||
|
||||
result = await user_cart_service.delete_user_cart(user_id)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_user_cart(user_cart_service, mock_redis):
|
||||
"""Тест проверки наличия корзины пользователя"""
|
||||
user_id = 12345
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 10,
|
||||
'total_price': 50000
|
||||
}
|
||||
|
||||
# Проверяем, что корзины нет
|
||||
result = await user_cart_service.has_user_cart(user_id)
|
||||
assert result is False
|
||||
|
||||
# Сохраняем данные
|
||||
await user_cart_service.save_user_cart(user_id, cart_data)
|
||||
|
||||
# Проверяем, что корзина есть
|
||||
result = await user_cart_service.has_user_cart(user_id)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_user_cart_not_found(user_cart_service):
|
||||
"""Тест проверки отсутствия корзины пользователя"""
|
||||
user_id = 99999
|
||||
|
||||
result = await user_cart_service.has_user_cart(user_id)
|
||||
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user