From 03856d30ecb3e3746501816f50667fbb1fe25014 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Thu, 18 Sep 2025 23:50:01 +0300 Subject: [PATCH 01/33] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=81=20=D0=BF=D1=80=D0=B5=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=B0=20=D0=B2=20=D0=B1=D0=B5=D0=B7=D0=BB?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=D0=BD=D1=83=D1=8E=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/admin/users.py | 349 +++++++++++++++++++++++++++++++++++- 1 file changed, 348 insertions(+), 1 deletion(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 9b7a9c2a..69db8535 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription +from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType from app.database.crud.user import get_user_by_id from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, @@ -18,6 +18,7 @@ from app.services.user_service import UserService from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago from app.services.remnawave_service import RemnaWaveService +from app.external.remnawave_api import TrafficLimitStrategy from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id logger = logging.getLogger(__name__) @@ -324,6 +325,10 @@ async def show_user_subscription( types.InlineKeyboardButton( text="🔄 Тип подписки", callback_data=f"admin_sub_change_type_{user_id}" + ), + types.InlineKeyboardButton( + text="💳 Купить подписку", + callback_data=f"admin_sub_buy_{user_id}" ) ] ] @@ -2633,3 +2638,345 @@ def register_handlers(dp: Dispatcher): change_subscription_type_confirm, F.data.startswith("admin_sub_type_") ) + + # Регистрация обработчика покупки подписки администратором + dp.callback_query.register( + admin_buy_subscription, + F.data.startswith("admin_sub_buy_") + ) + + # Регистрация дополнительных обработчиков для покупки подписки + dp.callback_query.register( + admin_buy_subscription_confirm, + F.data.startswith("admin_buy_sub_confirm_") + ) + + dp.callback_query.register( + admin_buy_subscription_execute, + F.data.startswith("admin_buy_sub_execute_") + ) + + +@admin_required +@error_handler +async def admin_buy_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """ + Обработчик покупки подписки администратором от имени пользователя + """ + # Извлекаем ID пользователя из callback_data + user_id = int(callback.data.split('_')[-1]) + + # Получаем информацию о пользователе + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + # Проверяем, есть ли у пользователя подписка + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + # Получаем доступные периоды подписки + available_periods = settings.get_available_subscription_periods() + + # Создаем клавиатуру с выбором периода + period_buttons = [] + for period in available_periods: + price_attr = f"PRICE_{period}_DAYS" + if hasattr(settings, price_attr): + price_kopeks = getattr(settings, price_attr) + price_rubles = price_kopeks // 100 + period_buttons.append([ + types.InlineKeyboardButton( + text=f"{period} дней ({price_rubles} ₽)", + callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}" + ) + ]) + + # Добавляем кнопку отмены + period_buttons.append([ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_user_subscription_{user_id}" + ) + ]) + + # Формируем текст сообщения + text = f"💳 Покупка подписки для пользователя\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Выберите период подписки:\n" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """ + Подтверждение покупки подписки администратором + """ + # Извлекаем параметры из callback_data + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + # Получаем информацию о пользователе + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + # Проверяем, достаточно ли средств на балансе пользователя + if target_user.balance_kopeks < price_kopeks: + missing_kopeks = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств на балансе пользователя\n\n" + f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + await callback.answer() + return + + # Формируем текст подтверждения + price_rubles = price_kopeks // 100 + text = f"💳 Подтверждение покупки подписки\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"📅 Период подписки: {period_days} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Вы уверены, что хотите купить подписку для этого пользователя?" + + # Создаем клавиатуру подтверждения + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_sub_buy_{user_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """ + Выполнение покупки подписки администратором + """ + # Извлекаем параметры из callback_data + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + # Получаем информацию о пользователе + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + # Проверяем, достаточно ли средств на балансе пользователя + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True) + return + + try: + # Списываем средства с балансе пользователя + from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка подписки на {period_days} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + # Обновляем существующую подписку + if subscription: + current_time = datetime.utcnow() + + # Если подписка истекла, обновляем дату начала + if subscription.end_date <= current_time: + subscription.start_date = current_time + + # Продлеваем подписку + subscription.end_date = current_time + timedelta(days=period_days) + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = current_time + + # Если подписка была триальной или неактивной, устанавливаем параметры платной подписки + if subscription.is_trial or not subscription.is_active: + subscription.is_trial = False # Преобразуем триал в платную подписку + # Устанавливаем параметры для платной подписки + if subscription.traffic_limit_gb != 0: # 0 означает безлимитный трафик + subscription.traffic_limit_gb = 0 + subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT + # Обнуляем использованный трафик только если это была триальная подписка + if subscription.is_trial: + subscription.traffic_used_gb = 0.0 + + await db.commit() + await db.refresh(subscription) + + # Создаем транзакцию + from app.database.crud.transaction import create_transaction + transaction = await create_transaction( + db=db, + user_id=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price_kopeks, + description=f"Продление подписки на {period_days} дней (администратор)" + ) + + # Обновляем пользователя в RemnaWave + try: + from app.services.remnawave_service import RemnaWaveService + from app.external.remnawave_api import UserStatus, TrafficLimitStrategy + remnawave_service = RemnaWaveService() + + # Проверяем, есть ли у пользователя UUID в RemnaWave + if target_user.remnawave_uuid: + # Обновляем существующего пользователя + async with remnawave_service.api as api: + remnawave_user = await api.update_user( + uuid=target_user.remnawave_uuid, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + expire_at=subscription.end_date, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + else: + # Создаем нового пользователя + username = f"user_{target_user.telegram_id}" + async with remnawave_service.api as api: + remnawave_user = await api.create_user( + username=username, + expire_at=subscription.end_date, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + telegram_id=target_user.telegram_id, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + + # Сохраняем UUID созданного пользователя + if remnawave_user and hasattr(remnawave_user, 'uuid'): + target_user.remnawave_uuid = remnawave_user.uuid + await db.commit() + + if remnawave_user: + logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave") + else: + logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave") + except Exception as e: + logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}") + + message = f"✅ Подписка пользователя продлена на {period_days} дней" + else: + # Создаем новую подписку (этот случай маловероятен, но на всякий случай) + message = "❌ Ошибка: у пользователя нет существующей подписки" + + # Отправляем уведомление администратору + await callback.message.edit_text( + f"{message}\n\n" + f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + + # Отправляем уведомление пользователю (если бот доступен) + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор продлил вашу подписку\n\n" + f"📅 Подписка продлена на {period_days} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}") + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка покупки подписки администратором: {e}") + await callback.answer("❌ Ошибка при покупке подписки", show_alert=True) + + # Откатываем изменения в случае ошибки + await db.rollback() From ac1b0ec8b39b3a3619700eabad7bf6fc6ce9554d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:25:06 +0300 Subject: [PATCH 02/33] Update users.py --- app/handlers/admin/users.py | 605 +++++++++++++++++------------------- 1 file changed, 283 insertions(+), 322 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 69db8535..b1e0a92a 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -2332,6 +2332,289 @@ async def change_subscription_type( ) await callback.answer() +@admin_required +@error_handler +async def admin_buy_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if not subscription: + await callback.answer("❌ У пользователя нет подписки", show_alert=True) + return + + available_periods = settings.get_available_subscription_periods() + + period_buttons = [] + for period in available_periods: + price_attr = f"PRICE_{period}_DAYS" + if hasattr(settings, price_attr): + price_kopeks = getattr(settings, price_attr) + price_rubles = price_kopeks // 100 + period_buttons.append([ + types.InlineKeyboardButton( + text=f"{period} дней ({price_rubles} ₽)", + callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}" + ) + ]) + + period_buttons.append([ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_user_subscription_{user_id}" + ) + ]) + + text = f"💳 Покупка подписки для пользователя\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Выберите период подписки:\n" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if target_user.balance_kopeks < price_kopeks: + missing_kopeks = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств на балансе пользователя\n\n" + f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + await callback.answer() + return + + price_rubles = price_kopeks // 100 + text = f"💳 Подтверждение покупки подписки\n\n" + text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + text += f"📅 Период подписки: {period_days} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" + text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "Вы уверены, что хотите купить подписку для этого пользователя?" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_sub_buy_{user_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_subscription_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + parts = callback.data.split('_') + user_id = int(parts[4]) + period_days = int(parts[5]) + price_kopeks = int(parts[6]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + subscription = profile["subscription"] + + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True) + return + + try: + from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка подписки на {period_days} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + if subscription: + current_time = datetime.utcnow() + + if subscription.end_date <= current_time: + subscription.start_date = current_time + + subscription.end_date = current_time + timedelta(days=period_days) + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = current_time + + if subscription.is_trial or not subscription.is_active: + subscription.is_trial = False + if subscription.traffic_limit_gb != 0: + subscription.traffic_limit_gb = 0 + subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT + if subscription.is_trial: + subscription.traffic_used_gb = 0.0 + + await db.commit() + await db.refresh(subscription) + + from app.database.crud.transaction import create_transaction + transaction = await create_transaction( + db=db, + user_id=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price_kopeks, + description=f"Продление подписки на {period_days} дней (администратор)" + ) + + try: + from app.services.remnawave_service import RemnaWaveService + from app.external.remnawave_api import UserStatus, TrafficLimitStrategy + remnawave_service = RemnaWaveService() + + if target_user.remnawave_uuid: + async with remnawave_service.api as api: + remnawave_user = await api.update_user( + uuid=target_user.remnawave_uuid, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + expire_at=subscription.end_date, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + else: + username = f"user_{target_user.telegram_id}" + async with remnawave_service.api as api: + remnawave_user = await api.create_user( + username=username, + expire_at=subscription.end_date, + status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_strategy=TrafficLimitStrategy.MONTH, + telegram_id=target_user.telegram_id, + hwid_device_limit=subscription.device_limit, + description=settings.format_remnawave_user_description( + full_name=target_user.full_name, + username=target_user.username, + telegram_id=target_user.telegram_id + ), + active_internal_squads=subscription.connected_squads + ) + + if remnawave_user and hasattr(remnawave_user, 'uuid'): + target_user.remnawave_uuid = remnawave_user.uuid + await db.commit() + + if remnawave_user: + logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave") + else: + logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave") + except Exception as e: + logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}") + + message = f"✅ Подписка пользователя продлена на {period_days} дней" + else: + message = "❌ Ошибка: у пользователя нет существующей подписки" + + await callback.message.edit_text( + f"{message}\n\n" + f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="⬅️ Назад к подписке", + callback_data=f"admin_user_subscription_{user_id}" + )] + ]) + ) + + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор продлил вашу подписку\n\n" + f"📅 Подписка продлена на {period_days} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}") + + await callback.answer() + + except Exception as e: + logger.error(f"Ошибка покупки подписки администратором: {e}") + await callback.answer("❌ Ошибка при покупке подписки", show_alert=True) + + await db.rollback() + @admin_required @error_handler @@ -2657,326 +2940,4 @@ def register_handlers(dp: Dispatcher): ) -@admin_required -@error_handler -async def admin_buy_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - """ - Обработчик покупки подписки администратором от имени пользователя - """ - # Извлекаем ID пользователя из callback_data - user_id = int(callback.data.split('_')[-1]) - - # Получаем информацию о пользователе - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - target_user = profile["user"] - subscription = profile["subscription"] - - # Проверяем, есть ли у пользователя подписка - if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) - return - - # Получаем доступные периоды подписки - available_periods = settings.get_available_subscription_periods() - - # Создаем клавиатуру с выбором периода - period_buttons = [] - for period in available_periods: - price_attr = f"PRICE_{period}_DAYS" - if hasattr(settings, price_attr): - price_kopeks = getattr(settings, price_attr) - price_rubles = price_kopeks // 100 - period_buttons.append([ - types.InlineKeyboardButton( - text=f"{period} дней ({price_rubles} ₽)", - callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}" - ) - ]) - - # Добавляем кнопку отмены - period_buttons.append([ - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_user_subscription_{user_id}" - ) - ]) - - # Формируем текст сообщения - text = f"💳 Покупка подписки для пользователя\n\n" - text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" - text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" - text += "Выберите период подписки:\n" - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons) - ) - await callback.answer() - -@admin_required -@error_handler -async def admin_buy_subscription_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - """ - Подтверждение покупки подписки администратором - """ - # Извлекаем параметры из callback_data - parts = callback.data.split('_') - user_id = int(parts[4]) - period_days = int(parts[5]) - price_kopeks = int(parts[6]) - - # Получаем информацию о пользователе - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - target_user = profile["user"] - subscription = profile["subscription"] - - # Проверяем, достаточно ли средств на балансе пользователя - if target_user.balance_kopeks < price_kopeks: - missing_kopeks = price_kopeks - target_user.balance_kopeks - await callback.message.edit_text( - f"❌ Недостаточно средств на балансе пользователя\n\n" - f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n" - f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n" - f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n" - f"Пополните баланс пользователя перед покупкой.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="⬅️ Назад к подписке", - callback_data=f"admin_user_subscription_{user_id}" - )] - ]) - ) - await callback.answer() - return - - # Формируем текст подтверждения - price_rubles = price_kopeks // 100 - text = f"💳 Подтверждение покупки подписки\n\n" - text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" - text += f"📅 Период подписки: {period_days} дней\n" - text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" - text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" - text += "Вы уверены, что хотите купить подписку для этого пользователя?" - - # Создаем клавиатуру подтверждения - keyboard = [ - [ - types.InlineKeyboardButton( - text="✅ Подтвердить", - callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}" - ) - ], - [ - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_sub_buy_{user_id}" - ) - ] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() - - -@admin_required -@error_handler -async def admin_buy_subscription_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - """ - Выполнение покупки подписки администратором - """ - # Извлекаем параметры из callback_data - parts = callback.data.split('_') - user_id = int(parts[4]) - period_days = int(parts[5]) - price_kopeks = int(parts[6]) - - # Получаем информацию о пользователе - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - target_user = profile["user"] - subscription = profile["subscription"] - - # Проверяем, достаточно ли средств на балансе пользователя - if target_user.balance_kopeks < price_kopeks: - await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True) - return - - try: - # Списываем средства с балансе пользователя - from app.database.crud.user import subtract_user_balance - success = await subtract_user_balance( - db, target_user, price_kopeks, - f"Покупка подписки на {period_days} дней (администратор)" - ) - - if not success: - await callback.answer("❌ Ошибка списания средств", show_alert=True) - return - - # Обновляем существующую подписку - if subscription: - current_time = datetime.utcnow() - - # Если подписка истекла, обновляем дату начала - if subscription.end_date <= current_time: - subscription.start_date = current_time - - # Продлеваем подписку - subscription.end_date = current_time + timedelta(days=period_days) - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.updated_at = current_time - - # Если подписка была триальной или неактивной, устанавливаем параметры платной подписки - if subscription.is_trial or not subscription.is_active: - subscription.is_trial = False # Преобразуем триал в платную подписку - # Устанавливаем параметры для платной подписки - if subscription.traffic_limit_gb != 0: # 0 означает безлимитный трафик - subscription.traffic_limit_gb = 0 - subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT - # Обнуляем использованный трафик только если это была триальная подписка - if subscription.is_trial: - subscription.traffic_used_gb = 0.0 - - await db.commit() - await db.refresh(subscription) - - # Создаем транзакцию - from app.database.crud.transaction import create_transaction - transaction = await create_transaction( - db=db, - user_id=target_user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=price_kopeks, - description=f"Продление подписки на {period_days} дней (администратор)" - ) - - # Обновляем пользователя в RemnaWave - try: - from app.services.remnawave_service import RemnaWaveService - from app.external.remnawave_api import UserStatus, TrafficLimitStrategy - remnawave_service = RemnaWaveService() - - # Проверяем, есть ли у пользователя UUID в RemnaWave - if target_user.remnawave_uuid: - # Обновляем существующего пользователя - async with remnawave_service.api as api: - remnawave_user = await api.update_user( - uuid=target_user.remnawave_uuid, - status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, - expire_at=subscription.end_date, - traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, - hwid_device_limit=subscription.device_limit, - description=settings.format_remnawave_user_description( - full_name=target_user.full_name, - username=target_user.username, - telegram_id=target_user.telegram_id - ), - active_internal_squads=subscription.connected_squads - ) - else: - # Создаем нового пользователя - username = f"user_{target_user.telegram_id}" - async with remnawave_service.api as api: - remnawave_user = await api.create_user( - username=username, - expire_at=subscription.end_date, - status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, - traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, - traffic_limit_strategy=TrafficLimitStrategy.MONTH, - telegram_id=target_user.telegram_id, - hwid_device_limit=subscription.device_limit, - description=settings.format_remnawave_user_description( - full_name=target_user.full_name, - username=target_user.username, - telegram_id=target_user.telegram_id - ), - active_internal_squads=subscription.connected_squads - ) - - # Сохраняем UUID созданного пользователя - if remnawave_user and hasattr(remnawave_user, 'uuid'): - target_user.remnawave_uuid = remnawave_user.uuid - await db.commit() - - if remnawave_user: - logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave") - else: - logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave") - except Exception as e: - logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}") - - message = f"✅ Подписка пользователя продлена на {period_days} дней" - else: - # Создаем новую подписку (этот случай маловероятен, но на всякий случай) - message = "❌ Ошибка: у пользователя нет существующей подписки" - - # Отправляем уведомление администратору - await callback.message.edit_text( - f"{message}\n\n" - f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" - f"💰 Списано: {settings.format_price(price_kopeks)}\n" - f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="⬅️ Назад к подписке", - callback_data=f"admin_user_subscription_{user_id}" - )] - ]) - ) - - # Отправляем уведомление пользователю (если бот доступен) - try: - if callback.bot: - await callback.bot.send_message( - chat_id=target_user.telegram_id, - text=f"💳 Администратор продлил вашу подписку\n\n" - f"📅 Подписка продлена на {period_days} дней\n" - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" - f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", - parse_mode="HTML" - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}") - - await callback.answer() - - except Exception as e: - logger.error(f"Ошибка покупки подписки администратором: {e}") - await callback.answer("❌ Ошибка при покупке подписки", show_alert=True) - - # Откатываем изменения в случае ошибки - await db.rollback() From cb959bdee761a58a184e45bd8ce7f888ff42edc7 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:30:14 +0300 Subject: [PATCH 03/33] Update docker-hub.yml --- .github/workflows/docker-hub.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index d4a3ee11..03b2186f 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -72,7 +72,7 @@ jobs: with: context: . file: ./Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: ${{ steps.version.outputs.should_push }} tags: ${{ steps.version.outputs.tags }} build-args: | From 559dd1df71dec5de95faffe17c57d8a852512344 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:30:37 +0300 Subject: [PATCH 04/33] Update docker-registry.yml --- .github/workflows/docker-registry.yml | 65 +++++++++++---------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 957fb389..fdcc9937 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -30,12 +30,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - network=host - name: Log in to Container Registry - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -50,31 +47,26 @@ jobs: if [[ $GITHUB_REF == refs/tags/* ]]; then VERSION=${GITHUB_REF#refs/tags/} - echo "🏷️ Собираем релизную версию: $VERSION" + echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.4" - echo "🚀 Собираем версию из main: $VERSION" + VERSION="v2.3.4-$(git rev-parse --short HEAD)" + echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)" - echo "🧪 Собираем dev версию: $VERSION" + echo "🧪 Building dev version: $VERSION" else VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)" - echo "🔀 Собираем PR версию: $VERSION" + echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT # Определяем, нужно ли пушить образ if [[ "${{ github.event_name }}" == "pull_request" ]]; then - if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then - echo "should_push=true" >> $GITHUB_OUTPUT - echo "✅ PR из того же репозитория - будем пушить" - else - echo "should_push=false" >> $GITHUB_OUTPUT - echo "⚠️ PR из внешнего форка - только build без push" - fi + echo "should_push=false" >> $GITHUB_OUTPUT + echo "⚠️ PR - only build without push" else echo "should_push=true" >> $GITHUB_OUTPUT - echo "✅ Push/Tag - будем пушить" + echo "✅ Push/Tag - will push" fi - name: Extract metadata @@ -105,18 +97,12 @@ jobs: VERSION=${{ steps.version.outputs.version }} BUILD_DATE=${{ steps.version.outputs.build_date }} VCS_REF=${{ steps.version.outputs.short_sha }} - cache-from: | - type=gha - type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache - cache-to: | - type=gha,mode=max - type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - build-contexts: | - alpine=docker-image://alpine:latest + cache-from: type=gha + cache-to: type=gha,mode=max - name: Generate security report uses: docker/scout-action@v1 - if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true' + if: github.event_name == 'pull_request' with: command: quickview,compare image: ${{ steps.meta.outputs.tags }} @@ -125,30 +111,31 @@ jobs: only-severities: critical,high write-comment: true github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true - name: Build Summary if: steps.version.outputs.should_push == 'true' run: | echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY - echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY - echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY + echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY - name: Build Summary (No Push) if: steps.version.outputs.should_push == 'false' run: | echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY - echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY - echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY + echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY From f79393c46e0f6bd18d4820cec649ba7eda644f00 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:38:52 +0300 Subject: [PATCH 05/33] Create dependabot.yml --- .github/workflows/dependabot.yml | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/dependabot.yml diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml new file mode 100644 index 00000000..f527fee0 --- /dev/null +++ b/.github/workflows/dependabot.yml @@ -0,0 +1,71 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 10 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "deps" + include: "scope" + labels: + - "dependencies" + - "python" + allow: + - dependency-type: "all" + groups: + python-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 5 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "ci" + include: "scope" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 3 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "docker" + include: "scope" + labels: + - "dependencies" + - "docker" From 863b5df0c2f9a683998d96b025ac1e318485257d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:41:41 +0300 Subject: [PATCH 06/33] Delete .github/workflows/dependabot.yml --- .github/workflows/dependabot.yml | 71 -------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 .github/workflows/dependabot.yml diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml deleted file mode 100644 index f527fee0..00000000 --- a/.github/workflows/dependabot.yml +++ /dev/null @@ -1,71 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "09:00" - timezone: "Europe/Moscow" - open-pull-requests-limit: 10 - reviewers: - - "fr1ngg" - assignees: - - "fr1ngg" - commit-message: - prefix: "deps" - include: "scope" - labels: - - "dependencies" - - "python" - allow: - - dependency-type: "all" - groups: - python-dependencies: - patterns: - - "*" - update-types: - - "minor" - - "patch" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "10:00" - timezone: "Europe/Moscow" - open-pull-requests-limit: 5 - reviewers: - - "fr1ngg" - assignees: - - "fr1ngg" - commit-message: - prefix: "ci" - include: "scope" - labels: - - "dependencies" - - "github-actions" - groups: - github-actions: - patterns: - - "*" - - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - day: "tuesday" - time: "09:00" - timezone: "Europe/Moscow" - open-pull-requests-limit: 3 - reviewers: - - "fr1ngg" - assignees: - - "fr1ngg" - commit-message: - prefix: "docker" - include: "scope" - labels: - - "dependencies" - - "docker" From 86ec46dbe7ad9250b6fe4b111aa52694eb06a2eb Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:42:10 +0300 Subject: [PATCH 07/33] Create dependabot.yml --- .github/dependabot.yml | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f527fee0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,71 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 10 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "deps" + include: "scope" + labels: + - "dependencies" + - "python" + allow: + - dependency-type: "all" + groups: + python-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 5 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "ci" + include: "scope" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + timezone: "Europe/Moscow" + open-pull-requests-limit: 3 + reviewers: + - "fr1ngg" + assignees: + - "fr1ngg" + commit-message: + prefix: "docker" + include: "scope" + labels: + - "dependencies" + - "docker" From f3616abec2b86e8681f1aafe9395fbd1ed07214b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 00:42:59 +0000 Subject: [PATCH 08/33] ci(deps): bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [docker/build-push-action](https://github.com/docker/build-push-action). Updates `actions/checkout` from 4 to 5 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) Updates `docker/build-push-action` from 5 to 6 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/build-push-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-hub.yml | 4 ++-- .github/workflows/docker-registry.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 03b2186f..c923a1ca 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -68,7 +68,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index fdcc9937..7fdccff5 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -24,7 +24,7 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -85,7 +85,7 @@ jobs: type=raw,value=${{ steps.version.outputs.version }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile From 5e46507f04bf20f44ab96a91cabb5f1067719ba1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 00:43:05 +0000 Subject: [PATCH 09/33] docker(deps): bump python from 3.11-slim to 3.13-slim Bumps python from 3.11-slim to 3.13-slim. --- updated-dependencies: - dependency-name: python dependency-version: 3.13-slim dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d09a5920..1f4698fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim AS builder +FROM python:3.13-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ @@ -12,7 +12,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.txt -FROM python:3.11-slim +FROM python:3.13-slim ARG VERSION="v2.3.4" ARG BUILD_DATE From 0c1db66be6f473eee4681c0d27d0255d6bba0c6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 00:43:47 +0000 Subject: [PATCH 10/33] deps(deps): bump the python-dependencies group with 12 updates --- updated-dependencies: - dependency-name: aiogram dependency-version: 3.22.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: aiohttp dependency-version: 3.12.15 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: asyncpg dependency-version: 0.30.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: sqlalchemy dependency-version: 2.0.43 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: alembic dependency-version: 1.16.5 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: aiosqlite dependency-version: 0.21.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: pydantic dependency-version: 2.11.9 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: pydantic-settings dependency-version: 2.10.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: python-dotenv dependency-version: 1.1.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: yookassa dependency-version: 3.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: apscheduler dependency-version: 3.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: python-dateutil dependency-version: 2.9.0.post0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies ... Signed-off-by: dependabot[bot] --- requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index 86531135..cd016ba4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,28 @@ # Основные зависимости -aiogram==3.7.0 -aiohttp==3.9.1 -asyncpg==0.29.0 -SQLAlchemy==2.0.25 -alembic==1.13.1 -aiosqlite==0.19.0 +aiogram==3.22.0 +aiohttp==3.12.15 +asyncpg==0.30.0 +SQLAlchemy==2.0.43 +alembic==1.16.5 +aiosqlite==0.21.0 # Дополнительные зависимости -pydantic==2.5.3 -pydantic-settings==2.1.0 -python-dotenv==1.0.0 +pydantic==2.11.9 +pydantic-settings==2.10.1 +python-dotenv==1.1.1 redis==5.0.1 # YooKassa SDK -yookassa==3.0.0 +yookassa==3.7.0 # Логирование и мониторинг structlog==23.2.0 # Планировщик задач для техработ -APScheduler==3.10.4 +APScheduler==3.11.0 # Утилиты -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 pytz==2023.4 cryptography>=41.0.0 qrcode[pil]==7.4.2 From e269f31056cb701b7957daf2d2643d7851f4b083 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 03:44:57 +0300 Subject: [PATCH 11/33] Update dependabot.yml --- .github/dependabot.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f527fee0..a786407c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,9 +9,9 @@ updates: timezone: "Europe/Moscow" open-pull-requests-limit: 10 reviewers: - - "fr1ngg" + - "Fr1ngg" assignees: - - "fr1ngg" + - "Fr1ngg" commit-message: prefix: "deps" include: "scope" @@ -37,9 +37,9 @@ updates: timezone: "Europe/Moscow" open-pull-requests-limit: 5 reviewers: - - "fr1ngg" + - "Fr1ngg" assignees: - - "fr1ngg" + - "Fr1ngg" commit-message: prefix: "ci" include: "scope" @@ -60,9 +60,9 @@ updates: timezone: "Europe/Moscow" open-pull-requests-limit: 3 reviewers: - - "fr1ngg" + - "Fr1ngg" assignees: - - "fr1ngg" + - "Fr1ngg" commit-message: prefix: "docker" include: "scope" From ec3430db278e30dd5da988c6ce09476cf8040feb Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 04:34:32 +0300 Subject: [PATCH 12/33] Update states.py --- app/states.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/states.py b/app/states.py index b0479c08..218e6aee 100644 --- a/app/states.py +++ b/app/states.py @@ -16,6 +16,7 @@ class SubscriptionStates(StatesGroup): adding_devices = State() extending_subscription = State() confirming_traffic_reset = State() + cart_saved_for_topup = State() class BalanceStates(StatesGroup): waiting_for_amount = State() From e43f6afd8a5d6a183f57cae612c5f383fa1e00c0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 04:39:24 +0300 Subject: [PATCH 13/33] Update subscription.py --- app/handlers/subscription.py | 131 +++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index af67d840..81eba8b1 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -517,7 +517,97 @@ async def start_subscription_purchase( await state.set_state(SubscriptionStates.selecting_period) await callback.answer() +async def save_cart_and_redirect_to_topup( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + missing_amount: int +): + from app.handlers.balance import show_payment_methods + + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.set_state(SubscriptionStates.cart_saved_for_topup) + await state.update_data({ + **data, + 'saved_cart': True, + 'missing_amount': missing_amount, + 'return_to_cart': True + }) + + await callback.message.edit_text( + f"💰 Недостаточно средств для оформления подписки\n\n" + f"Требуется: {texts.format_price(missing_amount)}\n" + f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n" + f"🛒 Ваша корзина сохранена!\n" + f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" + f"Выберите способ пополнения:", + reply_markup=get_payment_methods_keyboard_with_cart(db_user.language), + parse_mode="HTML" + ) +async def return_to_saved_cart( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + data = await state.get_data() + texts = get_texts(db_user.language) + + if not data.get('saved_cart'): + await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True) + return + + total_price = data.get('total_price', 0) + + if db_user.balance_kopeks < total_price: + missing_amount = total_price - db_user.balance_kopeks + await callback.message.edit_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) + ) + return + + from app.utils.pricing_utils import calculate_months_from_days, format_period_description + + countries = await _get_available_countries() + selected_countries_names = [] + + months_in_period = calculate_months_from_days(data['period_days']) + period_display = format_period_description(data['period_days'], db_user.language) + + for country in countries: + if country['uuid'] in 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']} ГБ" + else: + traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{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"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + 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_add_countries( callback: types.CallbackQuery, @@ -2217,12 +2307,18 @@ async def confirm_purchase( if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks - await callback.message.edit_text( - texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), - ) - await callback.answer() - return + + subscription = db_user.subscription + if not subscription or subscription.is_trial: + await save_cart_and_redirect_to_topup(callback, state, db_user, missing_kopeks) + return + else: + await callback.message.edit_text( + texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), + reply_markup=get_insufficient_balance_keyboard(db_user.language), + ) + await callback.answer() + return try: success = await subtract_user_balance( @@ -3505,6 +3601,19 @@ async def confirm_switch_traffic( await callback.answer() +async def clear_saved_cart( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + await state.clear() + + from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) + + await callback.answer("🗑️ Корзина очищена") + async def execute_switch_traffic( callback: types.CallbackQuery, @@ -3802,6 +3911,16 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_confirm", SubscriptionStates.confirming_purchase ) + + dp.callback_query.register( + return_to_saved_cart, + F.data == "return_to_saved_cart" + ) + + dp.callback_query.register( + clear_saved_cart, + F.data == "clear_saved_cart" + ) dp.callback_query.register( handle_autopay_menu, From 7baebd85c7a8e2aaa7c1049d1ccf27f71c665f2d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 04:40:56 +0300 Subject: [PATCH 14/33] Update balance.py --- app/handlers/balance.py | 71 +++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/app/handlers/balance.py b/app/handlers/balance.py index f01f9588..9f2ea2be 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -26,30 +26,22 @@ TRANSACTIONS_PER_PAGE = 10 def get_quick_amount_buttons(language: str) -> list: - """ - Генерирует кнопки быстрого выбора суммы пополнения на основе - AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS - """ if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: return [] buttons = [] periods = settings.get_available_subscription_periods() - # Ограничиваем до 6 кнопок (3 ряда по 2 кнопки) periods = periods[:6] for period in periods: - # Получаем цену из настроек price_attr = f"PRICE_{period}_DAYS" if hasattr(settings, price_attr): price_kopeks = getattr(settings, price_attr) price_rubles = price_kopeks // 100 - # Создаем callback_data для каждой кнопки callback_data = f"quick_amount_{price_kopeks}" - # Добавляем кнопку buttons.append( types.InlineKeyboardButton( text=f"{price_rubles} ₽ ({period} дней)", @@ -57,7 +49,6 @@ def get_quick_amount_buttons(language: str) -> list: ) ) - # Разбиваем кнопки на ряды (по 2 в ряд) keyboard_rows = [] for i in range(0, len(buttons), 2): keyboard_rows.append(buttons[i:i + 2]) @@ -386,7 +377,67 @@ async def start_tribute_payment( await callback.answer("❌ Ошибка создания платежа", show_alert=True) await callback.answer() - + +async def handle_successful_topup_with_cart( + user_id: int, + amount_kopeks: int, + bot, + db: AsyncSession +): + from app.database.crud.user import get_user_by_id + from aiogram.fsm.context import FSMContext + from aiogram.fsm.storage.base import StorageKey + from app.bot import dp + + user = await get_user_by_id(db, user_id) + if not user: + return + + storage = dp.storage + key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id) + + try: + state_data = await storage.get_data(key) + current_state = await storage.get_state(key) + + if (current_state == "SubscriptionStates:cart_saved_for_topup" and + state_data.get('saved_cart')): + + texts = get_texts(user.language) + total_price = state_data.get('total_price', 0) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="🛒 Вернуться к оформлению подписки", + callback_data="return_to_saved_cart" + )], + [types.InlineKeyboardButton( + text="💰 Мой баланс", + callback_data="menu_balance" + )], + [types.InlineKeyboardButton( + text="🏠 Главное меню", + callback_data="back_to_menu" + )] + ]) + + success_text = ( + f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n" + f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n" + f"🛒 У вас есть сохраненная корзина подписки\n" + f"Стоимость: {texts.format_price(total_price)}\n\n" + f"Хотите продолжить оформление?" + ) + + await bot.send_message( + chat_id=user.telegram_id, + text=success_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}") @error_handler async def request_support_topup( From 8273a31512ea761c78be0191c708f08ae926428c Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 04:41:44 +0300 Subject: [PATCH 15/33] Update inline.py --- app/keyboards/inline.py | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a55bd6c6..48222cfa 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -246,6 +246,51 @@ def get_subscription_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) +def get_payment_methods_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + keyboard = get_payment_methods_keyboard(0, language) + + # Добавляем кнопку "Очистить корзину" + keyboard.inline_keyboard.append([ + InlineKeyboardButton( + text="🗑️ Очистить корзину и вернуться", + callback_data="clear_saved_cart" + ) + ]) + + return keyboard + +def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data="subscription_confirm" + )], + [InlineKeyboardButton( + text="🗑️ Очистить корзину", + callback_data="clear_saved_cart" + )], + [InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_menu" + )] + ]) + +def get_insufficient_balance_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="💰 Пополнить баланс", + callback_data="balance_topup" + )], + [InlineKeyboardButton( + text="🗑️ Очистить корзину", + callback_data="clear_saved_cart" + )], + [InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_menu" + )] + ]) + def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ From efd8b191ff37916e27c3e55190839b911eb1cc42 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 04:46:31 +0300 Subject: [PATCH 16/33] Update subscription.py --- app/handlers/subscription.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 81eba8b1..b5805fdb 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -37,7 +37,10 @@ from app.keyboards.inline import ( get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, get_devices_management_keyboard, get_device_reset_confirm_keyboard, - get_device_management_help_keyboard + get_device_management_help_keyboard, + get_payment_methods_keyboard_with_cart, + get_subscription_confirm_keyboard_with_cart, + get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService From 4d109b948a6707f186301e1c01856248057e4145 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 05:10:33 +0300 Subject: [PATCH 17/33] Update docker-hub.yml --- .github/workflows/docker-hub.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index c923a1ca..d5383124 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.4-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi From 0cb9ab5704bde437776e8e98c419ba9bfb832aac Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 05:10:45 +0300 Subject: [PATCH 18/33] Update docker-registry.yml --- .github/workflows/docker-registry.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 7fdccff5..7a9947ca 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.4-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.4-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.4-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT From 7ef7b1d10744c35b86fec8d8f637f4aa37741e08 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 05:10:54 +0300 Subject: [PATCH 19/33] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1f4698fb..ad2b4347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.3.4" +ARG VERSION="v2.3.5" ARG BUILD_DATE ARG VCS_REF From 04476ed46396576e3a0397797c19f4fe63d7b0fd Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 05:38:46 +0300 Subject: [PATCH 20/33] Update docker-hub.yml --- .github/workflows/docker-hub.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index d5383124..86bda870 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -72,7 +72,7 @@ jobs: with: context: . file: ./Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: ${{ steps.version.outputs.should_push }} tags: ${{ steps.version.outputs.tags }} build-args: | From 719d656a647b7a84a36e46608c2322860c138fcd Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 05:39:04 +0300 Subject: [PATCH 21/33] Update docker-registry.yml --- .github/workflows/docker-registry.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 7a9947ca..e825ae52 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -89,7 +89,7 @@ jobs: with: context: . file: ./Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: ${{ steps.version.outputs.should_push }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 7f4899b885847d70c59a7b2444ea296e953f7872 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Fri, 19 Sep 2025 08:40:29 +0300 Subject: [PATCH 22/33] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=BF=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/crud/user.py | 11 ++- app/handlers/admin/users.py | 187 +++++++++++++++++++++++++++++++++-- app/keyboards/admin.py | 18 +++- app/services/user_service.py | 5 +- app/states.py | 3 + 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 004c5610..ff25714b 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -216,7 +216,8 @@ async def get_users_list( offset: int = 0, limit: int = 50, search: Optional[str] = None, - status: Optional[UserStatus] = None + status: Optional[UserStatus] = None, + order_by_balance: bool = False ) -> List[User]: query = select(User).options(selectinload(User.subscription)) @@ -237,7 +238,13 @@ async def get_users_list( query = query.where(or_(*conditions)) - query = query.order_by(User.created_at.desc()).offset(offset).limit(limit) + # Сортировка по балансу в порядке убывания, если order_by_balance=True + if order_by_balance: + query = query.order_by(User.balance_kopeks.desc()) + else: + query = query.order_by(User.created_at.desc()) + + query = query.offset(offset).limit(limit) result = await db.execute(query) return result.scalars().all() diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index b1e0a92a..02596078 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -11,7 +11,8 @@ from app.database.models import User, UserStatus, Subscription, SubscriptionStat from app.database.crud.user import get_user_by_id from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, - get_admin_pagination_keyboard, get_confirmation_keyboard + get_admin_pagination_keyboard, get_confirmation_keyboard, + get_admin_users_filters_keyboard ) from app.localization.texts import get_texts from app.services.user_service import UserService @@ -58,15 +59,36 @@ async def show_users_menu( await callback.answer() +@admin_required +@error_handler +async def show_users_filters( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + + text = "⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:" + + await callback.message.edit_text( + text, + reply_markup=get_admin_users_filters_keyboard(db_user.language) + ) + await callback.answer() + + @admin_required @error_handler async def show_users_list( callback: types.CallbackQuery, db_user: User, db: AsyncSession, + state: FSMContext, page: int = 1 ): + # Сбрасываем состояние, так как мы в обычном списке + await state.set_state(None) + user_service = UserService() users_data = await user_service.get_users_page(db, page=page, limit=10) @@ -152,20 +174,139 @@ async def show_users_list( await callback.answer() +@admin_required +@error_handler +async def show_users_list_by_balance( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + # Устанавливаем состояние, чтобы отслеживать, откуда пришел пользователь + await state.set_state(AdminStates.viewing_user_from_balance_list) + + user_service = UserService() + users_data = await user_service.get_users_page(db, page=page, limit=10, order_by_balance=True) + + if not users_data["users"]: + await callback.message.edit_text( + "👥 Пользователи не найдены", + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + return + + text = f"👥 Список пользователей по балансу (стр. {page}/{users_data['total_pages']})\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + + for user in users_data["users"]: + if user.status == UserStatus.ACTIVE.value: + status_emoji = "✅" + elif user.status == UserStatus.BLOCKED.value: + status_emoji = "🚫" + else: + status_emoji = "🗑️" + + subscription_emoji = "" + if user.subscription: + if user.subscription.is_trial: + subscription_emoji = "🎁" + elif user.subscription.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + else: + subscription_emoji = "❌" + + button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" + + if user.balance_kopeks > 0: + button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + + # Добавляем дату окончания подписки, если есть подписка + if user.subscription and user.subscription.end_date: + days_left = (user.subscription.end_date - datetime.utcnow()).days + button_text += f" | 📅 {days_left}д" + + if len(button_text) > 60: + short_name = user.full_name + if len(short_name) > 20: + short_name = short_name[:17] + "..." + + button_text = f"{status_emoji} {subscription_emoji} {short_name}" + if user.balance_kopeks > 0: + button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + + keyboard.append([ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_user_manage_{user.id}" + ) + ]) + + if users_data["total_pages"] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data["current_page"], + users_data["total_pages"], + "admin_users_balance_list", + "admin_users", + db_user.language + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"), + types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats") + ], + [ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + @admin_required @error_handler async def handle_users_list_pagination_fixed( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): try: callback_parts = callback.data.split('_') page = int(callback_parts[-1]) - await show_users_list(callback, db_user, db, page) + await show_users_list(callback, db_user, db, state, page) except (ValueError, IndexError) as e: logger.error(f"Ошибка парсинга номера страницы: {e}") - await show_users_list(callback, db_user, db, 1) + await show_users_list(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_balance_list_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + try: + callback_parts = callback.data.split('_') + page = int(callback_parts[-1]) + await show_users_list_by_balance(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_balance(callback, db_user, db, state, 1) @admin_required @@ -564,11 +705,18 @@ async def process_user_search( async def show_user_management( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): user_id = int(callback.data.split('_')[-1]) + # Проверяем, откуда пришел пользователь + back_callback = "admin_users_list" + + # Если callback_data содержит информацию о том, что мы пришли из списка по балансу + # В реальности это сложно определить, поэтому будем использовать состояние + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) @@ -621,13 +769,19 @@ async def show_user_management( else: text += "\nПодписка: Отсутствует" + # Проверяем состояние, чтобы определить, откуда пришел пользователь + current_state = await state.get_state() + if current_state == AdminStates.viewing_user_from_balance_list: + back_callback = "admin_users_balance_filter" + await callback.message.edit_text( text, - reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language) + reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) ) await callback.answer() + @admin_required @error_handler async def start_balance_edit( @@ -2756,6 +2910,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_users_list_page_") ) + dp.callback_query.register( + handle_users_balance_list_pagination, + F.data.startswith("admin_users_balance_list_page_") + ) + dp.callback_query.register( start_user_search, F.data == "admin_users_search" @@ -2938,6 +3097,22 @@ def register_handlers(dp: Dispatcher): admin_buy_subscription_execute, F.data.startswith("admin_buy_sub_execute_") ) + + # Регистрация обработчиков для фильтрации пользователей + dp.callback_query.register( + show_users_filters, + F.data == "admin_users_filters" + ) + + dp.callback_query.register( + show_users_list_by_balance, + F.data == "admin_users_balance_filter" + ) + + dp.callback_query.register( + show_users_list_by_balance, + F.data.startswith("admin_users_balance_list_page_") + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 067c1e83..d7e44296 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -107,12 +107,26 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"), InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive") ], + [ + InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users") ] ]) +def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter") + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + ] + ]) + + def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ @@ -236,7 +250,7 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup: +def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup: keyboard = [ [ InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"), @@ -267,7 +281,7 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = ]) keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list") + InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/services/user_service.py b/app/services/user_service.py index f26e14b2..c2dcd37f 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -145,13 +145,14 @@ class UserService: db: AsyncSession, page: int = 1, limit: int = 20, - status: Optional[UserStatus] = None + status: Optional[UserStatus] = None, + order_by_balance: bool = False ) -> Dict[str, Any]: try: offset = (page - 1) * limit users = await get_users_list( - db, offset=offset, limit=limit, status=status + db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance ) total_count = await get_users_count(db, status=status) diff --git a/app/states.py b/app/states.py index 218e6aee..9f826d63 100644 --- a/app/states.py +++ b/app/states.py @@ -70,6 +70,9 @@ class AdminStates(StatesGroup): editing_welcome_text = State() waiting_for_message_buttons = "waiting_for_message_buttons" + + # Состояния для отслеживания источника перехода + viewing_user_from_balance_list = State() class SupportStates(StatesGroup): waiting_for_message = State() From cfcb35c7eeed0ef7204658552a81c2c5e9827036 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 10:40:05 +0300 Subject: [PATCH 23/33] Add subscription checkout draft resume support --- app/handlers/menu.py | 40 ++- app/handlers/subscription.py | 283 ++++++++++++------ app/keyboards/inline.py | 37 ++- app/localization/texts.py | 4 + app/services/subscription_checkout_service.py | 52 ++++ app/utils/cache.py | 11 +- 6 files changed, 312 insertions(+), 115 deletions(-) create mode 100644 app/services/subscription_checkout_service.py diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 6c67aa14..d1716fe2 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -11,29 +11,36 @@ from app.localization.texts import get_texts from app.database.models import User from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) async def show_main_menu( - callback: types.CallbackQuery, - db_user: User, + callback: types.CallbackQuery, + db_user: User, db: AsyncSession ): texts = get_texts(db_user.language) - + from datetime import datetime db_user.last_activity = datetime.utcnow() await db.commit() - + has_active_subscription = bool(db_user.subscription) subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -44,11 +51,13 @@ async def show_main_menu( subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() + async def mark_user_as_had_paid_subscription( db: AsyncSession, user: User @@ -101,17 +110,20 @@ async def handle_back_to_menu( db: AsyncSession ): await state.clear() - + texts = get_texts(db_user.language) - + has_active_subscription = db_user.subscription is not None subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -121,13 +133,13 @@ async def handle_back_to_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() - def _get_subscription_status(user: User, texts) -> str: if not user.subscription: return "❌ Отсутствует" diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index af67d840..a5fddb76 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -43,6 +43,12 @@ from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.subscription_checkout_service import ( + clear_subscription_checkout_draft, + get_subscription_checkout_draft, + save_subscription_checkout_draft, + should_offer_checkout_resume, +) from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -56,6 +62,109 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + +async def _prepare_subscription_summary( + db_user: User, + data: Dict[str, Any], + texts, +) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + ) + + summary_data = dict(data) + countries = await _get_available_countries() + + months_in_period = calculate_months_from_days(summary_data['period_days']) + period_display = format_period_description(summary_data['period_days'], db_user.language) + + base_price = PERIOD_PRICES[summary_data['period_days']] + + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + traffic_price_per_month = settings.get_traffic_price(traffic_limit) + final_traffic_gb = traffic_limit + else: + traffic_gb = summary_data.get('traffic_gb', 0) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) + final_traffic_gb = traffic_gb + + total_traffic_price = traffic_price_per_month * months_in_period + + countries_price_per_month = 0 + selected_countries_names: List[str] = [] + selected_server_prices: List[int] = [] + + selected_country_ids = set(summary_data.get('countries', [])) + for country in countries: + if country['uuid'] in selected_country_ids: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + selected_countries_names.append(country['name']) + selected_server_prices.append(server_price_per_month * months_in_period) + + total_countries_price = countries_price_per_month * months_in_period + + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + total_devices_price = devices_price_per_month * months_in_period + + total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + + monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + + if not is_valid: + raise ValueError("Subscription price calculation validation failed") + + summary_data['total_price'] = total_price + summary_data['server_prices_for_period'] = selected_server_prices + + if settings.is_traffic_fixed(): + if final_traffic_gb == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{final_traffic_gb} ГБ" + else: + if summary_data.get('traffic_gb', 0) == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + + details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + + if total_traffic_price > 0: + details_lines.append( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + ) + if total_countries_price > 0: + details_lines.append( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + ) + if total_devices_price > 0: + details_lines.append( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + ) + + details_text = "\n".join(details_lines) + + summary_text = ( + "📋 Сводка заказа\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {devices_selected}\n\n" + "💰 Детализация стоимости:\n" + f"{details_text}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + return summary_text, summary_data + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -664,6 +773,13 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -1455,7 +1571,10 @@ async def confirm_add_devices( missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2048,107 +2167,29 @@ async def devices_continue( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation - if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - + data = await state.get_data() texts = get_texts(db_user.language) - - countries = await _get_available_countries() - selected_countries_names = [] - - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) - - base_price = PERIOD_PRICES[data['period_days']] - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() - else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - countries_price_per_month = 0 - selected_server_prices = [] - - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) - - if not is_valid: + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - - data['total_price'] = total_price - data['server_prices_for_period'] = selected_server_prices - await state.set_data(data) - - if settings.is_traffic_fixed(): - if final_traffic_gb == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{final_traffic_gb} ГБ" - else: - if data['traffic_gb'] == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{data['traffic_gb']} ГБ" - - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] - if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" - ) - if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" - ) - if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" - ) - details_text = "\n".join(details_lines) + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) - await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML", ) - + await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() @@ -2164,7 +2205,14 @@ async def confirm_purchase( data = await state.get_data() texts = get_texts(db_user.language) - + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(data['period_days']) @@ -2219,11 +2267,16 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return + purchase_completed = False + try: success = await subtract_user_balance( db, db_user, final_price, @@ -2234,7 +2287,10 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2396,6 +2452,7 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + purchase_completed = True logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") except Exception as e: @@ -2405,9 +2462,48 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + await state.clear() await callback.answer() + + +async def resume_subscription_checkout( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, +): + texts = get_texts(db_user.language) + + draft = await get_subscription_checkout_draft(db_user.id) + + if not draft: + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + except ValueError as exc: + logger.error( + f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" + ) + await clear_subscription_checkout_draft(db_user.id) + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + await state.set_data(prepared_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() async def add_traffic( callback: types.CallbackQuery, db_user: User, @@ -3802,6 +3898,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_confirm", SubscriptionStates.confirming_purchase ) + + dp.callback_query.register( + resume_subscription_checkout, + F.data == "subscription_resume_checkout", + ) dp.callback_query.register( handle_autopay_menu, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a55bd6c6..b1fd2310 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -56,7 +56,8 @@ def get_main_menu_keyboard( has_active_subscription: bool = False, subscription_is_active: bool = False, balance_kopeks: int = 0, - subscription=None + subscription=None, + show_resume_checkout: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -131,7 +132,15 @@ def get_main_menu_keyboard( keyboard.append(subscription_buttons) else: keyboard.append([subscription_buttons[0]]) - + + if show_resume_checkout: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + keyboard.extend([ [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), @@ -166,17 +175,31 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard( + language: str = "ru", + resume_callback: str | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( text=texts.GO_TO_BALANCE_TOP_UP, callback_data="balance_topup", ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")], - ]) + ] + ] + + if resume_callback: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_subscription_keyboard( diff --git a/app/localization/texts.py b/app/localization/texts.py index 50360cac..499c03f2 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -253,6 +253,8 @@ class RussianTexts(Texts): Пополните баланс на {amount} и попробуйте снова. """ GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Вернуться к оформлению" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Сохраненный заказ не найден. Соберите подписку заново." SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!" BALANCE_INFO = """ @@ -541,6 +543,8 @@ To get started, select interface language: Top up {amount} and try again.""" GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." LANGUAGES = { diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py new file mode 100644 index 00000000..e49b6a5e --- /dev/null +++ b/app/services/subscription_checkout_service.py @@ -0,0 +1,52 @@ +from typing import Optional + +from app.database.models import User +from app.utils.cache import UserCache + + +_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_TTL_SECONDS = 3600 + + +async def save_subscription_checkout_draft( + user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS +) -> bool: + """Persist subscription checkout draft data in cache.""" + + return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) + + +async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: + """Retrieve subscription checkout draft from cache.""" + + return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def clear_subscription_checkout_draft(user_id: int) -> bool: + """Remove stored subscription checkout draft for the user.""" + + return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def has_subscription_checkout_draft(user_id: int) -> bool: + draft = await get_subscription_checkout_draft(user_id) + return draft is not None + + +def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: + """ + Determine whether checkout resume button should be available for the user. + + Only users without an active paid subscription or users currently on trial + are eligible to continue assembling the subscription from the saved draft. + """ + + if not has_draft: + return False + + subscription = getattr(user, "subscription", None) + + if subscription is None: + return True + + return bool(getattr(subscription, "is_trial", False)) diff --git a/app/utils/cache.py b/app/utils/cache.py index 408f7246..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -203,14 +203,19 @@ class UserCache: @staticmethod async def set_user_session( - user_id: int, - session_key: str, - data: Any, + user_id: int, + session_key: str, + data: Any, expire: int = 1800 ) -> bool: key = cache_key("session", user_id, session_key) return await cache.set(key, data, expire) + @staticmethod + async def delete_user_session(user_id: int, session_key: str) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.delete(key) + class SystemCache: From 57cc2d2e3a12c5d1bc37ce979293bf1d4e15065b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 10:54:16 +0300 Subject: [PATCH 24/33] Fix subscription checkout resume after top-ups --- app/handlers/menu.py | 40 ++- app/handlers/stars_payments.py | 30 +- app/handlers/subscription.py | 292 ++++++++++++------ app/keyboards/inline.py | 37 ++- app/localization/texts.py | 4 + app/services/payment_service.py | 160 +++------- app/services/subscription_checkout_service.py | 52 ++++ app/utils/cache.py | 11 +- 8 files changed, 369 insertions(+), 257 deletions(-) create mode 100644 app/services/subscription_checkout_service.py diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 6c67aa14..d1716fe2 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -11,29 +11,36 @@ from app.localization.texts import get_texts from app.database.models import User from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) async def show_main_menu( - callback: types.CallbackQuery, - db_user: User, + callback: types.CallbackQuery, + db_user: User, db: AsyncSession ): texts = get_texts(db_user.language) - + from datetime import datetime db_user.last_activity = datetime.utcnow() await db.commit() - + has_active_subscription = bool(db_user.subscription) subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -44,11 +51,13 @@ async def show_main_menu( subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() + async def mark_user_as_had_paid_subscription( db: AsyncSession, user: User @@ -101,17 +110,20 @@ async def handle_back_to_menu( db: AsyncSession ): await state.clear() - + texts = get_texts(db_user.language) - + has_active_subscription = db_user.subscription is not None subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -121,13 +133,13 @@ async def handle_back_to_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() - def _get_subscription_status(user: User, texts) -> str: if not user.subscription: return "❌ Отсутствует" diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index db169b32..ea3b2aee 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,13 +1,11 @@ import logging from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id -from app.localization.texts import get_texts logger = logging.getLogger(__name__) @@ -91,33 +89,7 @@ async def handle_successful_payment( if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await payment_service.build_topup_success_keyboard(user) await message.answer( f"🎉 Платеж успешно обработан!\n\n" diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index af67d840..8a868c7d 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -43,6 +43,12 @@ from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.subscription_checkout_service import ( + clear_subscription_checkout_draft, + get_subscription_checkout_draft, + save_subscription_checkout_draft, + should_offer_checkout_resume, +) from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -56,6 +62,109 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + +async def _prepare_subscription_summary( + db_user: User, + data: Dict[str, Any], + texts, +) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + ) + + summary_data = dict(data) + countries = await _get_available_countries() + + months_in_period = calculate_months_from_days(summary_data['period_days']) + period_display = format_period_description(summary_data['period_days'], db_user.language) + + base_price = PERIOD_PRICES[summary_data['period_days']] + + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + traffic_price_per_month = settings.get_traffic_price(traffic_limit) + final_traffic_gb = traffic_limit + else: + traffic_gb = summary_data.get('traffic_gb', 0) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) + final_traffic_gb = traffic_gb + + total_traffic_price = traffic_price_per_month * months_in_period + + countries_price_per_month = 0 + selected_countries_names: List[str] = [] + selected_server_prices: List[int] = [] + + selected_country_ids = set(summary_data.get('countries', [])) + for country in countries: + if country['uuid'] in selected_country_ids: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + selected_countries_names.append(country['name']) + selected_server_prices.append(server_price_per_month * months_in_period) + + total_countries_price = countries_price_per_month * months_in_period + + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + total_devices_price = devices_price_per_month * months_in_period + + total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + + monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + + if not is_valid: + raise ValueError("Subscription price calculation validation failed") + + summary_data['total_price'] = total_price + summary_data['server_prices_for_period'] = selected_server_prices + + if settings.is_traffic_fixed(): + if final_traffic_gb == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{final_traffic_gb} ГБ" + else: + if summary_data.get('traffic_gb', 0) == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + + details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + + if total_traffic_price > 0: + details_lines.append( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + ) + if total_countries_price > 0: + details_lines.append( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + ) + if total_devices_price > 0: + details_lines.append( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + ) + + details_text = "\n".join(details_lines) + + summary_text = ( + "📋 Сводка заказа\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {devices_selected}\n\n" + "💰 Детализация стоимости:\n" + f"{details_text}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + return summary_text, summary_data + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -664,6 +773,13 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -1455,7 +1571,10 @@ async def confirm_add_devices( missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2048,107 +2167,29 @@ async def devices_continue( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation - if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - + data = await state.get_data() texts = get_texts(db_user.language) - - countries = await _get_available_countries() - selected_countries_names = [] - - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) - - base_price = PERIOD_PRICES[data['period_days']] - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() - else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - countries_price_per_month = 0 - selected_server_prices = [] - - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) - - if not is_valid: + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - - data['total_price'] = total_price - data['server_prices_for_period'] = selected_server_prices - await state.set_data(data) - - if settings.is_traffic_fixed(): - if final_traffic_gb == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{final_traffic_gb} ГБ" - else: - if data['traffic_gb'] == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{data['traffic_gb']} ГБ" - - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] - if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" - ) - if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" - ) - if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" - ) - details_text = "\n".join(details_lines) + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) - await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML", ) - + await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() @@ -2164,7 +2205,14 @@ async def confirm_purchase( data = await state.get_data() texts = get_texts(db_user.language) - + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(data['period_days']) @@ -2219,11 +2267,16 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return + purchase_completed = False + try: success = await subtract_user_balance( db, db_user, final_price, @@ -2234,7 +2287,10 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2396,6 +2452,7 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + purchase_completed = True logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") except Exception as e: @@ -2405,9 +2462,48 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + await state.clear() await callback.answer() + + +async def resume_subscription_checkout( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, +): + texts = get_texts(db_user.language) + + draft = await get_subscription_checkout_draft(db_user.id) + + if not draft: + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + except ValueError as exc: + logger.error( + f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" + ) + await clear_subscription_checkout_draft(db_user.id) + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + await state.set_data(prepared_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() async def add_traffic( callback: types.CallbackQuery, db_user: User, @@ -2705,14 +2801,15 @@ async def handle_subscription_cancel( db_user: User, db: AsyncSession ): - + texts = get_texts(db_user.language) - + await state.clear() - + await clear_subscription_checkout_draft(db_user.id) + from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) - + await callback.answer("❌ Покупка отменена") async def _get_available_countries(): @@ -3802,6 +3899,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_confirm", SubscriptionStates.confirming_purchase ) + + dp.callback_query.register( + resume_subscription_checkout, + F.data == "subscription_resume_checkout", + ) dp.callback_query.register( handle_autopay_menu, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a55bd6c6..b1fd2310 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -56,7 +56,8 @@ def get_main_menu_keyboard( has_active_subscription: bool = False, subscription_is_active: bool = False, balance_kopeks: int = 0, - subscription=None + subscription=None, + show_resume_checkout: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -131,7 +132,15 @@ def get_main_menu_keyboard( keyboard.append(subscription_buttons) else: keyboard.append([subscription_buttons[0]]) - + + if show_resume_checkout: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + keyboard.extend([ [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), @@ -166,17 +175,31 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard( + language: str = "ru", + resume_callback: str | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( text=texts.GO_TO_BALANCE_TOP_UP, callback_data="balance_topup", ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")], - ]) + ] + ] + + if resume_callback: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_subscription_keyboard( diff --git a/app/localization/texts.py b/app/localization/texts.py index 50360cac..499c03f2 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -253,6 +253,8 @@ class RussianTexts(Texts): Пополните баланс на {amount} и попробуйте снова. """ GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Вернуться к оформлению" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Сохраненный заказ не найден. Соберите подписку заново." SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!" BALANCE_INFO = """ @@ -541,6 +543,8 @@ To get started, select interface language: Top up {amount} and try again.""" GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." LANGUAGES = { diff --git a/app/services/payment_service.py b/app/services/payment_service.py index dc980dfc..0855be9e 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService from app.utils.currency_converter import currency_converter from app.database.database import get_db from app.localization.texts import get_texts +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) @@ -33,7 +37,49 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None - + + async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: + texts = get_texts(user.language if user else "ru") + + has_active_subscription = ( + user + and user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + ) + + first_button = InlineKeyboardButton( + text=( + texts.MENU_EXTEND_SUBSCRIPTION + if has_active_subscription + else texts.MENU_BUY_SUBSCRIPTION + ), + callback_data=( + "subscription_extend" if has_active_subscription else "menu_buy" + ), + ) + + keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]] + + if user: + draft_exists = await has_subscription_checkout_draft(user.id) + if should_offer_checkout_resume(user, draft_exists): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance") + ]) + keyboard_rows.append([ + InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + async def create_stars_invoice( self, amount_kopeks: int, @@ -124,33 +170,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -432,33 +452,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -545,33 +539,7 @@ class PaymentService: user = await get_user_by_telegram_id(db, telegram_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) message = ( f"✅ Платеж успешно завершен!\n\n" @@ -827,33 +795,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py new file mode 100644 index 00000000..e49b6a5e --- /dev/null +++ b/app/services/subscription_checkout_service.py @@ -0,0 +1,52 @@ +from typing import Optional + +from app.database.models import User +from app.utils.cache import UserCache + + +_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_TTL_SECONDS = 3600 + + +async def save_subscription_checkout_draft( + user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS +) -> bool: + """Persist subscription checkout draft data in cache.""" + + return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) + + +async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: + """Retrieve subscription checkout draft from cache.""" + + return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def clear_subscription_checkout_draft(user_id: int) -> bool: + """Remove stored subscription checkout draft for the user.""" + + return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def has_subscription_checkout_draft(user_id: int) -> bool: + draft = await get_subscription_checkout_draft(user_id) + return draft is not None + + +def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: + """ + Determine whether checkout resume button should be available for the user. + + Only users without an active paid subscription or users currently on trial + are eligible to continue assembling the subscription from the saved draft. + """ + + if not has_draft: + return False + + subscription = getattr(user, "subscription", None) + + if subscription is None: + return True + + return bool(getattr(subscription, "is_trial", False)) diff --git a/app/utils/cache.py b/app/utils/cache.py index 408f7246..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -203,14 +203,19 @@ class UserCache: @staticmethod async def set_user_session( - user_id: int, - session_key: str, - data: Any, + user_id: int, + session_key: str, + data: Any, expire: int = 1800 ) -> bool: key = cache_key("session", user_id, session_key) return await cache.set(key, data, expire) + @staticmethod + async def delete_user_session(user_id: int, session_key: str) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.delete(key) + class SystemCache: From b1a2b6774d3fc2330373d4a9d1106a5dbca3776f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 11:08:41 +0300 Subject: [PATCH 25/33] Show resume checkout option after Tribute top-ups --- app/handlers/menu.py | 40 ++- app/handlers/stars_payments.py | 30 +- app/handlers/subscription.py | 292 ++++++++++++------ app/keyboards/inline.py | 37 ++- app/localization/texts.py | 4 + app/services/payment_service.py | 160 +++------- app/services/subscription_checkout_service.py | 52 ++++ app/services/tribute_service.py | 41 +-- app/utils/cache.py | 11 +- 9 files changed, 376 insertions(+), 291 deletions(-) create mode 100644 app/services/subscription_checkout_service.py diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 6c67aa14..d1716fe2 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -11,29 +11,36 @@ from app.localization.texts import get_texts from app.database.models import User from app.utils.user_utils import mark_user_as_had_paid_subscription from app.database.crud.user_message import get_random_active_message +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) async def show_main_menu( - callback: types.CallbackQuery, - db_user: User, + callback: types.CallbackQuery, + db_user: User, db: AsyncSession ): texts = get_texts(db_user.language) - + from datetime import datetime db_user.last_activity = datetime.utcnow() await db.commit() - + has_active_subscription = bool(db_user.subscription) subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -44,11 +51,13 @@ async def show_main_menu( subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() + async def mark_user_as_had_paid_subscription( db: AsyncSession, user: User @@ -101,17 +110,20 @@ async def handle_back_to_menu( db: AsyncSession ): await state.clear() - + texts = get_texts(db_user.language) - + has_active_subscription = db_user.subscription is not None subscription_is_active = False - + if db_user.subscription: subscription_is_active = db_user.subscription.is_active - + menu_text = await get_main_menu_text(db_user, texts, db) - + + draft_exists = await has_subscription_checkout_draft(db_user.id) + show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + await callback.message.edit_text( menu_text, reply_markup=get_main_menu_keyboard( @@ -121,13 +133,13 @@ async def handle_back_to_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription + subscription=db_user.subscription, + show_resume_checkout=show_resume_checkout, ), parse_mode="HTML" ) await callback.answer() - def _get_subscription_status(user: User, texts) -> str: if not user.subscription: return "❌ Отсутствует" diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index db169b32..ea3b2aee 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,13 +1,11 @@ import logging from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.services.payment_service import PaymentService from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id -from app.localization.texts import get_texts logger = logging.getLogger(__name__) @@ -91,33 +89,7 @@ async def handle_successful_payment( if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await payment_service.build_topup_success_keyboard(user) await message.answer( f"🎉 Платеж успешно обработан!\n\n" diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index af67d840..8a868c7d 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -43,6 +43,12 @@ from app.localization.texts import get_texts from app.services.remnawave_service import RemnaWaveService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.subscription_checkout_service import ( + clear_subscription_checkout_draft, + get_subscription_checkout_draft, + save_subscription_checkout_draft, + should_offer_checkout_resume, +) from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -56,6 +62,109 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + +async def _prepare_subscription_summary( + db_user: User, + data: Dict[str, Any], + texts, +) -> Tuple[str, Dict[str, Any]]: + from app.utils.pricing_utils import ( + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, + ) + + summary_data = dict(data) + countries = await _get_available_countries() + + months_in_period = calculate_months_from_days(summary_data['period_days']) + period_display = format_period_description(summary_data['period_days'], db_user.language) + + base_price = PERIOD_PRICES[summary_data['period_days']] + + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + traffic_price_per_month = settings.get_traffic_price(traffic_limit) + final_traffic_gb = traffic_limit + else: + traffic_gb = summary_data.get('traffic_gb', 0) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) + final_traffic_gb = traffic_gb + + total_traffic_price = traffic_price_per_month * months_in_period + + countries_price_per_month = 0 + selected_countries_names: List[str] = [] + selected_server_prices: List[int] = [] + + selected_country_ids = set(summary_data.get('countries', [])) + for country in countries: + if country['uuid'] in selected_country_ids: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + selected_countries_names.append(country['name']) + selected_server_prices.append(server_price_per_month * months_in_period) + + total_countries_price = countries_price_per_month * months_in_period + + devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) + additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE + total_devices_price = devices_price_per_month * months_in_period + + total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + + monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + + if not is_valid: + raise ValueError("Subscription price calculation validation failed") + + summary_data['total_price'] = total_price + summary_data['server_prices_for_period'] = selected_server_prices + + if settings.is_traffic_fixed(): + if final_traffic_gb == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{final_traffic_gb} ГБ" + else: + if summary_data.get('traffic_gb', 0) == 0: + traffic_display = "Безлимитный" + else: + traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + + details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] + + if total_traffic_price > 0: + details_lines.append( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + ) + if total_countries_price > 0: + details_lines.append( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + ) + if total_devices_price > 0: + details_lines.append( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + ) + + details_text = "\n".join(details_lines) + + summary_text = ( + "📋 Сводка заказа\n\n" + f"📅 Период: {period_display}\n" + f"📊 Трафик: {traffic_display}\n" + f"🌍 Страны: {', '.join(selected_countries_names)}\n" + f"📱 Устройства: {devices_selected}\n\n" + "💰 Детализация стоимости:\n" + f"{details_text}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" + ) + + return summary_text, summary_data + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -664,6 +773,13 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -1455,7 +1571,10 @@ async def confirm_add_devices( missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2048,107 +2167,29 @@ async def devices_continue( db_user: User, db: AsyncSession ): - from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation - if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) return - + data = await state.get_data() texts = get_texts(db_user.language) - - countries = await _get_available_countries() - selected_countries_names = [] - - months_in_period = calculate_months_from_days(data['period_days']) - period_display = format_period_description(data['period_days'], db_user.language) - - base_price = PERIOD_PRICES[data['period_days']] - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() - else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - countries_price_per_month = 0 - selected_server_prices = [] - - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) - - if not is_valid: + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - - data['total_price'] = total_price - data['server_prices_for_period'] = selected_server_prices - await state.set_data(data) - - if settings.is_traffic_fixed(): - if final_traffic_gb == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{final_traffic_gb} ГБ" - else: - if data['traffic_gb'] == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{data['traffic_gb']} ГБ" - - details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] - if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" - ) - if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" - ) - if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" - ) - details_text = "\n".join(details_lines) + await state.set_data(prepared_data) + await save_subscription_checkout_draft(db_user.id, prepared_data) - summary_text = ( - "📋 Сводка заказа\n\n" - f"📅 Период: {period_display}\n" - f"📊 Трафик: {traffic_display}\n" - f"🌍 Страны: {', '.join(selected_countries_names)}\n" - f"📱 Устройства: {data['devices']}\n\n" - "💰 Детализация стоимости:\n" - f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" - "Подтверждаете покупку?" - ) - await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML", ) - + await state.set_state(SubscriptionStates.confirming_purchase) await callback.answer() @@ -2164,7 +2205,14 @@ async def confirm_purchase( data = await state.get_data() texts = get_texts(db_user.language) - + + await save_subscription_checkout_draft(db_user.id, dict(data)) + resume_callback = ( + "subscription_resume_checkout" + if should_offer_checkout_resume(db_user, True) + else None + ) + countries = await _get_available_countries() months_in_period = calculate_months_from_days(data['period_days']) @@ -2219,11 +2267,16 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return + purchase_completed = False + try: success = await subtract_user_balance( db, db_user, final_price, @@ -2234,7 +2287,10 @@ async def confirm_purchase( missing_kopeks = final_price - db_user.balance_kopeks await callback.message.edit_text( texts.INSUFFICIENT_BALANCE.format(amount=texts.format_price(missing_kopeks)), - reply_markup=get_insufficient_balance_keyboard(db_user.language), + reply_markup=get_insufficient_balance_keyboard( + db_user.language, + resume_callback=resume_callback, + ), ) await callback.answer() return @@ -2396,6 +2452,7 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + purchase_completed = True logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") except Exception as e: @@ -2405,9 +2462,48 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed: + await clear_subscription_checkout_draft(db_user.id) + await state.clear() await callback.answer() + + +async def resume_subscription_checkout( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, +): + texts = get_texts(db_user.language) + + draft = await get_subscription_checkout_draft(db_user.id) + + if not draft: + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + try: + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + except ValueError as exc: + logger.error( + f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" + ) + await clear_subscription_checkout_draft(db_user.id) + await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) + return + + await state.set_data(prepared_data) + await state.set_state(SubscriptionStates.confirming_purchase) + await save_subscription_checkout_draft(db_user.id, prepared_data) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + + await callback.answer() async def add_traffic( callback: types.CallbackQuery, db_user: User, @@ -2705,14 +2801,15 @@ async def handle_subscription_cancel( db_user: User, db: AsyncSession ): - + texts = get_texts(db_user.language) - + await state.clear() - + await clear_subscription_checkout_draft(db_user.id) + from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) - + await callback.answer("❌ Покупка отменена") async def _get_available_countries(): @@ -3802,6 +3899,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_confirm", SubscriptionStates.confirming_purchase ) + + dp.callback_query.register( + resume_subscription_checkout, + F.data == "subscription_resume_checkout", + ) dp.callback_query.register( handle_autopay_menu, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a55bd6c6..b1fd2310 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -56,7 +56,8 @@ def get_main_menu_keyboard( has_active_subscription: bool = False, subscription_is_active: bool = False, balance_kopeks: int = 0, - subscription=None + subscription=None, + show_resume_checkout: bool = False, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -131,7 +132,15 @@ def get_main_menu_keyboard( keyboard.append(subscription_buttons) else: keyboard.append([subscription_buttons[0]]) - + + if show_resume_checkout: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + keyboard.extend([ [ InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"), @@ -166,17 +175,31 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_insufficient_balance_keyboard( + language: str = "ru", + resume_callback: str | None = None, +) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( text=texts.GO_TO_BALANCE_TOP_UP, callback_data="balance_topup", ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")], - ]) + ] + ] + + if resume_callback: + keyboard.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data=resume_callback, + ) + ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_subscription_keyboard( diff --git a/app/localization/texts.py b/app/localization/texts.py index 50360cac..499c03f2 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -253,6 +253,8 @@ class RussianTexts(Texts): Пополните баланс на {amount} и попробуйте снова. """ GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Вернуться к оформлению" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Сохраненный заказ не найден. Соберите подписку заново." SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!" BALANCE_INFO = """ @@ -541,6 +543,8 @@ To get started, select interface language: Top up {amount} and try again.""" GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" + RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" + NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." LANGUAGES = { diff --git a/app/services/payment_service.py b/app/services/payment_service.py index dc980dfc..0855be9e 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService from app.utils.currency_converter import currency_converter from app.database.database import get_db from app.localization.texts import get_texts +from app.services.subscription_checkout_service import ( + has_subscription_checkout_draft, + should_offer_checkout_resume, +) logger = logging.getLogger(__name__) @@ -33,7 +37,49 @@ class PaymentService: self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None - + + async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup: + texts = get_texts(user.language if user else "ru") + + has_active_subscription = ( + user + and user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + ) + + first_button = InlineKeyboardButton( + text=( + texts.MENU_EXTEND_SUBSCRIPTION + if has_active_subscription + else texts.MENU_BUY_SUBSCRIPTION + ), + callback_data=( + "subscription_extend" if has_active_subscription else "menu_buy" + ), + ) + + keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]] + + if user: + draft_exists = await has_subscription_checkout_draft(user.id) + if should_offer_checkout_resume(user, draft_exists): + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data="subscription_resume_checkout", + ) + ]) + + keyboard_rows.append([ + InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance") + ]) + keyboard_rows.append([ + InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + async def create_stars_invoice( self, amount_kopeks: int, @@ -124,33 +170,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -432,33 +452,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, @@ -545,33 +539,7 @@ class PaymentService: user = await get_user_by_telegram_id(db, telegram_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) message = ( f"✅ Платеж успешно завершен!\n\n" @@ -827,33 +795,7 @@ class PaymentService: if self.bot: try: - user_language = user.language if user else "ru" - texts = get_texts(user_language) - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - ] - ) + keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py new file mode 100644 index 00000000..e49b6a5e --- /dev/null +++ b/app/services/subscription_checkout_service.py @@ -0,0 +1,52 @@ +from typing import Optional + +from app.database.models import User +from app.utils.cache import UserCache + + +_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_TTL_SECONDS = 3600 + + +async def save_subscription_checkout_draft( + user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS +) -> bool: + """Persist subscription checkout draft data in cache.""" + + return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) + + +async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: + """Retrieve subscription checkout draft from cache.""" + + return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def clear_subscription_checkout_draft(user_id: int) -> bool: + """Remove stored subscription checkout draft for the user.""" + + return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY) + + +async def has_subscription_checkout_draft(user_id: int) -> bool: + draft = await get_subscription_checkout_draft(user_id) + return draft is not None + + +def should_offer_checkout_resume(user: User, has_draft: bool) -> bool: + """ + Determine whether checkout resume button should be available for the user. + + Only users without an active paid subscription or users currently on trial + are eligible to continue assembling the subscription from the saved draft. + """ + + if not has_draft: + return False + + subscription = getattr(user, "subscription", None) + + if subscription is None: + return True + + return bool(getattr(subscription, "is_trial", False)) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 9daf0b0f..6d5de2eb 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -5,8 +5,6 @@ from datetime import datetime from aiogram import Bot from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from sqlalchemy.ext.asyncio import AsyncSession - from app.config import settings from app.database.database import get_db from app.database.models import Transaction, TransactionType, PaymentMethod @@ -15,7 +13,7 @@ from app.database.crud.transaction import ( ) from app.database.crud.user import get_user_by_telegram_id, add_user_balance from app.external.tribute import TributeService as TributeAPI -from app.localization.texts import get_texts +from app.services.payment_service import PaymentService logger = logging.getLogger(__name__) @@ -216,7 +214,7 @@ class TributeService: logger.error(f"Ошибка обработки возврата Tribute: {e}") async def _send_success_notification(self, user_id: int, amount_kopeks: int): - + try: amount_rubles = amount_kopeks / 100 @@ -224,34 +222,8 @@ class TributeService: user = await get_user_by_telegram_id(session, user_id) break - user_language = user.language if user else "ru" - texts = get_texts(user_language) - - has_active_subscription = ( - user - and user.subscription - and not user.subscription.is_trial - and user.subscription.is_active - ) - - first_button = InlineKeyboardButton( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ) - ) - - keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [first_button], - [InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ] - ) + payment_service = PaymentService(self.bot) + keyboard = await payment_service.build_topup_success_keyboard(user) text = ( f"✅ **Платеж успешно получен!**\n\n" @@ -267,10 +239,11 @@ class TributeService: reply_markup=keyboard, parse_mode="Markdown" ) - + except Exception as e: logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}") - + + async def _send_failure_notification(self, user_id: int): try: diff --git a/app/utils/cache.py b/app/utils/cache.py index 408f7246..aeed54f7 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -203,14 +203,19 @@ class UserCache: @staticmethod async def set_user_session( - user_id: int, - session_key: str, - data: Any, + user_id: int, + session_key: str, + data: Any, expire: int = 1800 ) -> bool: key = cache_key("session", user_id, session_key) return await cache.set(key, data, expire) + @staticmethod + async def delete_user_session(user_id: int, session_key: str) -> bool: + key = cache_key("session", user_id, session_key) + return await cache.delete(key) + class SystemCache: From db5f81e6be23dcb3296a2697dcd3899993869e5b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 11:38:11 +0300 Subject: [PATCH 26/33] Allow support contact to use URLs --- .env.example | 1 + README.md | 1 + app/config.py | 61 ++++++++++++++++++++++++++++++++++++++- app/handlers/balance.py | 20 ++++++++----- app/keyboards/inline.py | 5 +++- app/localization/texts.py | 2 +- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 84edbf42..2a02f129 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # ===== TELEGRAM BOT ===== BOT_TOKEN= ADMIN_IDS= +# Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support # Уведомления администраторов diff --git a/README.md b/README.md index 5479b8fb..e8c82ade 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв # ===== TELEGRAM BOT ===== BOT_TOKEN= ADMIN_IDS= +# Ссылка на поддержку: Telegram username (например, @support) или полный URL SUPPORT_USERNAME=@support # Уведомления администраторов diff --git a/app/config.py b/app/config.py index fffe0214..926444e2 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ import os import re +import html from collections import defaultdict from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings @@ -604,10 +605,68 @@ class Settings(BaseSettings): def get_traffic_price(self, gb: int) -> int: packages = self.get_traffic_packages() - + for package in packages: if package["gb"] == gb and package["enabled"]: return package["price"] + + def _clean_support_contact(self) -> str: + return (self.SUPPORT_USERNAME or "").strip() + + def get_support_contact_url(self) -> Optional[str]: + contact = self._clean_support_contact() + + if not contact: + return None + + if contact.startswith(("http://", "https://", "tg://")): + return contact + + contact_without_prefix = contact.lstrip("@") + + if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + return f"https://{contact_without_prefix}" + + if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + return f"https://{contact}" + + if "." in contact_without_prefix: + return f"https://{contact_without_prefix}" + + if contact_without_prefix: + return f"https://t.me/{contact_without_prefix}" + + return None + + def get_support_contact_display(self) -> str: + contact = self._clean_support_contact() + + if not contact: + return "" + + if contact.startswith("@"): + return contact + + if contact.startswith(("http://", "https://", "tg://")): + return contact + + if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + url = self.get_support_contact_url() + return url if url else contact + + contact_without_prefix = contact.lstrip("@") + + if "." in contact_without_prefix: + url = self.get_support_contact_url() + return url if url else contact + + if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix): + return f"@{contact_without_prefix}" + + return contact + + def get_support_contact_display_html(self) -> str: + return html.escape(self.get_support_contact_display()) enabled_packages = [pkg for pkg in packages if pkg["enabled"]] diff --git a/app/handlers/balance.py b/app/handlers/balance.py index f01f9588..e80b57d5 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -399,7 +399,7 @@ async def request_support_topup( 🛠️ Пополнение через поддержку Для пополнения баланса обратитесь в техподдержку: -{settings.SUPPORT_USERNAME} +{settings.get_support_contact_display_html()} Укажите: • ID: {db_user.telegram_id} @@ -416,8 +416,8 @@ async def request_support_topup( keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton( - text="💬 Написать в поддержку", - url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}" + text="💬 Написать в поддержку", + url=settings.get_support_contact_url() or "https://t.me/" )], [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] ]) @@ -606,7 +606,7 @@ async def process_yookassa_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -691,7 +691,7 @@ async def process_yookassa_sbp_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата происходит через защищенную систему YooKassa\n" f"✅ Принимаем СБП от всех банков-участников\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -752,7 +752,9 @@ async def check_yookassa_payment_status( elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_failed: - message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}" + message_text += ( + f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}" + ) await callback.answer(message_text, show_alert=True) @@ -890,7 +892,7 @@ async def process_cryptobot_payment_amount( f"4. Деньги поступят на баланс автоматически\n\n" f"🔒 Оплата проходит через защищенную систему CryptoBot\n" f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", reply_markup=keyboard, parse_mode="HTML" ) @@ -946,7 +948,9 @@ async def check_cryptobot_payment_status( elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_expired: - message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}" + message_text += ( + f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}" + ) await callback.answer(message_text, show_alert=True) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index b1fd2310..40f0fbdd 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -657,7 +657,10 @@ def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text=texts.CONTACT_SUPPORT, url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}") + InlineKeyboardButton( + text=texts.CONTACT_SUPPORT, + url=settings.get_support_contact_url() or "https://t.me/" + ) ], [ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") diff --git a/app/localization/texts.py b/app/localization/texts.py index 499c03f2..4ad43c7c 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -399,7 +399,7 @@ class RussianTexts(Texts): По всем вопросам обращайтесь к нашей поддержке: -👤 {settings.SUPPORT_USERNAME} +👤 {settings.get_support_contact_display_html()} Мы поможем с: • Настройкой подключения From c738b5703fea3a69ec15548b3dbf90097c721e4d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 12:15:56 +0300 Subject: [PATCH 27/33] Fix import cleanup --- app/bot.py | 23 +- app/database/crud/campaign.py | 258 ++++++ app/database/models.py | 73 +- app/handlers/admin/campaigns.py | 805 ++++++++++++++++++ app/handlers/start.py | 111 ++- app/keyboards/admin.py | 53 +- app/localization/texts.py | 14 +- app/services/campaign_service.py | 171 ++++ app/states.py | 9 + .../5d1f1f8b2e9a_add_advertising_campaigns.py | 70 ++ 10 files changed, 1561 insertions(+), 26 deletions(-) create mode 100644 app/database/crud/campaign.py create mode 100644 app/handlers/admin/campaigns.py create mode 100644 app/services/campaign_service.py create mode 100644 migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py diff --git a/app/bot.py b/app/bot.py index aaa30c53..1be1fd65 100644 --- a/app/bot.py +++ b/app/bot.py @@ -19,15 +19,23 @@ from app.handlers import ( referral, support, common ) from app.handlers.admin import ( - main as admin_main, users as admin_users, subscriptions as admin_subscriptions, - promocodes as admin_promocodes, messages as admin_messages, - monitoring as admin_monitoring, referrals as admin_referrals, - rules as admin_rules, remnawave as admin_remnawave, - statistics as admin_statistics, servers as admin_servers, + main as admin_main, + users as admin_users, + subscriptions as admin_subscriptions, + promocodes as admin_promocodes, + messages as admin_messages, + monitoring as admin_monitoring, + referrals as admin_referrals, + rules as admin_rules, + remnawave as admin_remnawave, + statistics as admin_statistics, + servers as admin_servers, maintenance as admin_maintenance, + campaigns as admin_campaigns, user_messages as admin_user_messages, - updates as admin_updates, backup as admin_backup, - welcome_text as admin_welcome_text + updates as admin_updates, + backup as admin_backup, + welcome_text as admin_welcome_text, ) from app.handlers.stars_payments import register_stars_handlers @@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_campaigns.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) admin_updates.register_handlers(dp) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py new file mode 100644 index 00000000..40313434 --- /dev/null +++ b/app/database/crud/campaign.py @@ -0,0 +1,258 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from sqlalchemy import and_, func, select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + AdvertisingCampaign, + AdvertisingCampaignRegistration, +) + +logger = logging.getLogger(__name__) + + +async def create_campaign( + db: AsyncSession, + *, + name: str, + start_parameter: str, + bonus_type: str, + created_by: Optional[int] = None, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, + subscription_traffic_gb: Optional[int] = None, + subscription_device_limit: Optional[int] = None, + subscription_squads: Optional[List[str]] = None, +) -> AdvertisingCampaign: + campaign = AdvertisingCampaign( + name=name, + start_parameter=start_parameter, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + subscription_traffic_gb=subscription_traffic_gb, + subscription_device_limit=subscription_device_limit, + subscription_squads=subscription_squads or [], + created_by=created_by, + is_active=True, + ) + + db.add(campaign) + await db.commit() + await db.refresh(campaign) + + logger.info( + "📣 Создана рекламная кампания %s (start=%s, bonus=%s)", + campaign.name, + campaign.start_parameter, + campaign.bonus_type, + ) + return campaign + + +async def get_campaign_by_id( + db: AsyncSession, campaign_id: int +) -> Optional[AdvertisingCampaign]: + result = await db.execute( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .where(AdvertisingCampaign.id == campaign_id) + ) + return result.scalar_one_or_none() + + +async def get_campaign_by_start_parameter( + db: AsyncSession, + start_parameter: str, + *, + only_active: bool = False, +) -> Optional[AdvertisingCampaign]: + stmt = select(AdvertisingCampaign).where( + AdvertisingCampaign.start_parameter == start_parameter + ) + if only_active: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def get_campaigns_list( + db: AsyncSession, + *, + offset: int = 0, + limit: int = 20, + include_inactive: bool = True, +) -> List[AdvertisingCampaign]: + stmt = ( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .order_by(AdvertisingCampaign.created_at.desc()) + .offset(offset) + .limit(limit) + ) + if not include_inactive: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_campaigns_count( + db: AsyncSession, *, is_active: Optional[bool] = None +) -> int: + stmt = select(func.count(AdvertisingCampaign.id)) + if is_active is not None: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active)) + + result = await db.execute(stmt) + return result.scalar_one() or 0 + + +async def update_campaign( + db: AsyncSession, + campaign: AdvertisingCampaign, + **kwargs, +) -> AdvertisingCampaign: + allowed_fields = { + "name", + "start_parameter", + "bonus_type", + "balance_bonus_kopeks", + "subscription_duration_days", + "subscription_traffic_gb", + "subscription_device_limit", + "subscription_squads", + "is_active", + } + + update_data = {key: value for key, value in kwargs.items() if key in allowed_fields} + + if not update_data: + return campaign + + update_data["updated_at"] = datetime.utcnow() + + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.id == campaign.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(campaign) + + logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data) + return campaign + + +async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: + await db.execute( + delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id) + ) + await db.commit() + logger.info("🗑️ Удалена рекламная кампания %s", campaign.name) + return True + + +async def record_campaign_registration( + db: AsyncSession, + *, + campaign_id: int, + user_id: int, + bonus_type: str, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, +) -> AdvertisingCampaignRegistration: + existing = await db.execute( + select(AdvertisingCampaignRegistration).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.user_id == user_id, + ) + ) + ) + registration = existing.scalar_one_or_none() + if registration: + return registration + + registration = AdvertisingCampaignRegistration( + campaign_id=campaign_id, + user_id=user_id, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + ) + db.add(registration) + await db.commit() + await db.refresh(registration) + + logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id) + return registration + + +async def get_campaign_statistics( + db: AsyncSession, + campaign_id: int, +) -> Dict[str, Optional[int]]: + result = await db.execute( + select( + func.count(AdvertisingCampaignRegistration.id), + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ), + func.max(AdvertisingCampaignRegistration.created_at), + ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + count, total_balance, last_registration = result.one() + + subscription_count_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.bonus_type == "subscription", + ) + ) + ) + + return { + "registrations": count or 0, + "balance_issued": total_balance or 0, + "subscription_issued": subscription_count_result.scalar() or 0, + "last_registration": last_registration, + } + + +async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: + total = await get_campaigns_count(db) + active = await get_campaigns_count(db, is_active=True) + inactive = await get_campaigns_count(db, is_active=False) + + registrations_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)) + ) + + balance_result = await db.execute( + select( + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ) + ) + ) + + subscription_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + AdvertisingCampaignRegistration.bonus_type == "subscription" + ) + ) + + return { + "total": total, + "active": active, + "inactive": inactive, + "registrations": registrations_result.scalar() or 0, + "balance_total": balance_result.scalar() or 0, + "subscription_total": subscription_result.scalar() or 0, + } diff --git a/app/database/models.py b/app/database/models.py index e12e1eb7..285eeb08 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3,8 +3,17 @@ from typing import Optional, List from enum import Enum from sqlalchemy import ( - Column, Integer, String, DateTime, Boolean, Text, - ForeignKey, Float, JSON, BigInteger + Column, + Integer, + String, + DateTime, + Boolean, + Text, + ForeignKey, + Float, + JSON, + BigInteger, + UniqueConstraint, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -666,7 +675,7 @@ class UserMessage(Base): class WelcomeText(Base): __tablename__ = "welcome_texts" - + id = Column(Integer, primary_key=True, index=True) text_content = Column(Text, nullable=False) is_active = Column(Boolean, default=True) @@ -674,5 +683,61 @@ class WelcomeText(Base): created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + creator = relationship("User", backref="created_welcome_texts") + + +class AdvertisingCampaign(Base): + __tablename__ = "advertising_campaigns" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + start_parameter = Column(String(64), nullable=False, unique=True, index=True) + bonus_type = Column(String(20), nullable=False) + + balance_bonus_kopeks = Column(Integer, default=0) + + subscription_duration_days = Column(Integer, nullable=True) + subscription_traffic_gb = Column(Integer, nullable=True) + subscription_device_limit = Column(Integer, nullable=True) + subscription_squads = Column(JSON, default=list) + + is_active = Column(Boolean, default=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign") + + @property + def is_balance_bonus(self) -> bool: + return self.bonus_type == "balance" + + @property + def is_subscription_bonus(self) -> bool: + return self.bonus_type == "subscription" + + +class AdvertisingCampaignRegistration(Base): + __tablename__ = "advertising_campaign_registrations" + __table_args__ = ( + UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + bonus_type = Column(String(20), nullable=False) + balance_bonus_kopeks = Column(Integer, default=0) + subscription_duration_days = Column(Integer, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + campaign = relationship("AdvertisingCampaign", back_populates="registrations") + user = relationship("User") + + @property + def balance_bonus_rubles(self) -> float: + return (self.balance_bonus_kopeks or 0) / 100 diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py new file mode 100644 index 00000000..68708b67 --- /dev/null +++ b/app/handlers/admin/campaigns.py @@ -0,0 +1,805 @@ +import logging +import re +from typing import List + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import ( + create_campaign, + delete_campaign, + get_campaign_by_id, + get_campaign_by_start_parameter, + get_campaign_statistics, + get_campaigns_count, + get_campaigns_list, + get_campaigns_overview, + update_campaign, +) +from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id +from app.database.models import User +from app.keyboards.admin import ( + get_admin_campaigns_keyboard, + get_admin_pagination_keyboard, + get_campaign_bonus_type_keyboard, + get_campaign_management_keyboard, + get_confirmation_keyboard, +) +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") +_CAMPAIGNS_PAGE_SIZE = 5 + + +def _format_campaign_summary(campaign, texts) -> str: + status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" + + if campaign.is_balance_bonus: + bonus_text = texts.format_price(campaign.balance_bonus_kopeks) + bonus_info = f"💰 Бонус на баланс: {bonus_text}" + else: + traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) + bonus_info = ( + "📱 Подписка: {days} д.\n" + "🌐 Трафик: {traffic}\n" + "📱 Устройства: {devices}" + ).format( + days=campaign.subscription_duration_days or 0, + traffic=traffic_text, + devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, + ) + + return ( + f"{campaign.name}\n" + f"Стартовый параметр: {campaign.start_parameter}\n" + f"Статус: {status}\n" + f"{bonus_info}\n" + ) + + +async def _get_bot_deep_link( + callback: types.CallbackQuery, start_parameter: str +) -> str: + bot = await callback.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +async def _get_bot_deep_link_from_message( + message: types.Message, start_parameter: str +) -> str: + bot = await message.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +def _build_campaign_servers_keyboard( + servers, selected_uuids: List[str] +) -> types.InlineKeyboardMarkup: + keyboard: List[List[types.InlineKeyboardButton]] = [] + + for server in servers[:20]: + is_selected = server.squad_uuid in selected_uuids + emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") + text = f"{emoji} {server.display_name}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=text, callback_data=f"campaign_toggle_server_{server.id}" + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="✅ Сохранить", callback_data="campaign_servers_save" + ), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"), + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +@admin_required +@error_handler +async def show_campaigns_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ( + "📣 Рекламные кампании\n\n" + f"Всего кампаний: {overview['total']}\n" + f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" + f"Регистраций: {overview['registrations']}\n" + f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" + f"Выдано подписок: {overview['subscription_total']}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_overall_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ["📊 Общая статистика кампаний\n"] + text.append(f"Всего кампаний: {overview['total']}") + text.append( + f"Активны: {overview['active']}, выключены: {overview['inactive']}" + ) + text.append(f"Всего регистраций: {overview['registrations']}") + text.append( + f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" + ) + text.append(f"Выдано подписок: {overview['subscription_total']}") + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + page = 1 + if callback.data.startswith("admin_campaigns_list_page_"): + try: + page = int(callback.data.split("_")[-1]) + except ValueError: + page = 1 + + offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE + campaigns = await get_campaigns_list( + db, + offset=offset, + limit=_CAMPAIGNS_PAGE_SIZE, + ) + total = await get_campaigns_count(db) + total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE) + + if not campaigns: + await callback.message.edit_text( + "❌ Рекламные кампании не найдены.", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="➕ Создать", callback_data="admin_campaigns_create" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ], + ] + ), + ) + await callback.answer() + return + + text_lines = ["📋 Список кампаний\n"] + + for campaign in campaigns: + registrations = len(campaign.registrations or []) + total_balance = sum( + r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] + ) + status = "🟢" if campaign.is_active else "⚪" + line = ( + f"{status} {campaign.name}{campaign.start_parameter}\n" + f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" + ) + if campaign.is_subscription_bonus: + line += f", подписка: {campaign.subscription_duration_days or 0} д." + else: + line += ", бонус: баланс" + text_lines.append(line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=f"🔍 {campaign.name}", + callback_data=f"admin_campaign_manage_{campaign.id}", + ) + ] + for campaign in campaigns + ] + + pagination = get_admin_pagination_keyboard( + current_page=page, + total_pages=total_pages, + callback_prefix="admin_campaigns_list", + back_callback="admin_campaigns", + language=db_user.language, + ) + + keyboard_rows.extend(pagination.inline_keyboard) + + await callback.message.edit_text( + "\n".join(text_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaign_detail( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + + text = ["📣 Управление кампанией\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"🔗 Ссылка: {deep_link}") + text.append("\n📊 Статистика") + text.append(f"• Регистраций: {stats['registrations']}") + text.append( + f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" + ) + text.append(f"• Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_campaign_status( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + new_status = not campaign.is_active + await update_campaign(db, campaign, is_active=new_status) + status_text = "включена" if new_status else "выключена" + logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) + + await show_campaign_detail(callback, db_user, db) + + +@admin_required +@error_handler +async def show_campaign_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + + text = ["📊 Статистика кампании\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"Регистраций: {stats['registrations']}") + text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") + text.append(f"Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_campaign_manage_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_delete_campaign( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + text = ( + "🗑️ Удаление кампании\n\n" + f"Название: {campaign.name}\n" + f"Параметр: {campaign.start_parameter}\n\n" + "Вы уверены, что хотите удалить кампанию?" + ) + + await callback.message.edit_text( + text, + reply_markup=get_confirmation_keyboard( + confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_callback=f"admin_campaign_manage_{campaign_id}", + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_campaign_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await delete_campaign(db, campaign) + await callback.message.edit_text( + "✅ Кампания удалена.", + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer("Удалено") + + +@admin_required +@error_handler +async def start_campaign_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + await callback.message.edit_text( + "🆕 Создание рекламной кампании\n\nВведите название кампании:", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await state.set_state(AdminStates.creating_campaign_name) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + name = message.text.strip() + if len(name) < 3 or len(name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + await state.update_data(campaign_name=name) + await state.set_state(AdminStates.creating_campaign_start) + await message.answer( + "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", + ) + + +@admin_required +@error_handler +async def process_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + start_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(start_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + existing = await get_campaign_by_start_parameter(db, start_param) + if existing: + await message.answer( + "❌ Кампания с таким параметром уже существует. Введите другой параметр." + ) + return + + await state.update_data(campaign_start_parameter=start_param) + await state.set_state(AdminStates.creating_campaign_bonus) + await message.answer( + "🎯 Выберите тип бонуса для кампании:", + reply_markup=get_campaign_bonus_type_keyboard(db_user.language), + ) + + +@admin_required +@error_handler +async def select_campaign_bonus_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + bonus_type = "balance" if callback.data.endswith("balance") else "subscription" + await state.update_data(campaign_bonus_type=bonus_type) + + if bonus_type == "balance": + await state.set_state(AdminStates.creating_campaign_balance) + await callback.message.edit_text( + "💰 Введите сумму бонуса на баланс (в рублях):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + else: + await state.set_state(AdminStates.creating_campaign_subscription_days) + await callback.message.edit_text( + "📅 Введите длительность подписки в днях (1-730):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_balance_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + data = await state.get_data() + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="balance", + balance_bonus_kopeks=amount_kopeks, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await message.answer( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + await state.update_data(campaign_subscription_days=days) + await state.set_state(AdminStates.creating_campaign_subscription_traffic) + await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") + + +@admin_required +@error_handler +async def process_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + await state.update_data(campaign_subscription_traffic=traffic) + await state.set_state(AdminStates.creating_campaign_subscription_devices) + await message.answer( + f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + await state.update_data(campaign_subscription_devices=devices) + await state.update_data(campaign_subscription_squads=[]) + await state.set_state(AdminStates.creating_campaign_subscription_servers) + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await message.answer( + "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", + ) + await state.clear() + return + + keyboard = _build_campaign_servers_keyboard(servers, []) + await message.answer( + "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def toggle_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + server_id = int(callback.data.split("_")[-1]) + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + data = await state.get_data() + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard(servers, selected) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def finalize_campaign_subscription( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + selected = data.get("campaign_subscription_squads", []) + + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="subscription", + subscription_duration_days=data.get("campaign_subscription_days"), + subscription_traffic_gb=data.get("campaign_subscription_traffic"), + subscription_device_limit=data.get("campaign_subscription_devices"), + subscription_squads=selected, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") + dp.callback_query.register( + show_campaigns_overall_stats, F.data == "admin_campaigns_stats" + ) + dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") + dp.callback_query.register( + show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") + ) + dp.callback_query.register( + start_campaign_creation, F.data == "admin_campaigns_create" + ) + dp.callback_query.register( + show_campaign_stats, F.data.startswith("admin_campaign_stats_") + ) + dp.callback_query.register( + show_campaign_detail, F.data.startswith("admin_campaign_manage_") + ) + dp.callback_query.register( + delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") + ) + dp.callback_query.register( + confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") + ) + dp.callback_query.register( + toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") + ) + dp.callback_query.register( + finalize_campaign_subscription, F.data == "campaign_servers_save" + ) + dp.callback_query.register( + toggle_campaign_server, F.data.startswith("campaign_toggle_server_") + ) + dp.callback_query.register( + select_campaign_bonus_type, F.data.startswith("campaign_bonus_") + ) + + dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) + dp.message.register( + process_campaign_start_parameter, AdminStates.creating_campaign_start + ) + dp.message.register( + process_campaign_balance_value, AdminStates.creating_campaign_balance + ) + dp.message.register( + process_campaign_subscription_days, + AdminStates.creating_campaign_subscription_days, + ) + dp.message.register( + process_campaign_subscription_traffic, + AdminStates.creating_campaign_subscription_traffic, + ) + dp.message.register( + process_campaign_subscription_devices, + AdminStates.creating_campaign_subscription_devices, + ) diff --git a/app/handlers/start.py b/app/handlers/start.py index b33ced5f..a786d3f8 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import RegistrationStates from app.database.crud.user import ( - get_user_by_telegram_id, create_user, get_user_by_referral_code + get_user_by_telegram_id, + create_user, + get_user_by_referral_code, +) +from app.database.crud.campaign import ( + get_campaign_by_start_parameter, + get_campaign_by_id, ) from app.database.models import UserStatus from app.keyboards.inline import ( @@ -17,15 +23,52 @@ from app.keyboards.inline import ( ) from app.localization.texts import get_texts from app.services.referral_service import process_referral_registration +from app.services.campaign_service import AdvertisingCampaignService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message -from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest logger = logging.getLogger(__name__) +async def _apply_campaign_bonus_if_needed( + db: AsyncSession, + user, + state_data: dict, + texts, +): + campaign_id = state_data.get("campaign_id") if state_data else None + if not campaign_id: + return None + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign or not campaign.is_active: + return None + + service = AdvertisingCampaignService() + result = await service.apply_campaign_bonus(db, user, campaign) + if not result.success: + return None + + if result.bonus_type == "balance": + amount_text = texts.format_price(result.balance_kopeks) + return texts.CAMPAIGN_BONUS_BALANCE.format( + amount=amount_text, + name=campaign.name, + ) + + if result.bonus_type == "subscription": + traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) + return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( + name=campaign.name, + days=result.subscription_days, + traffic=traffic_text, + devices=result.subscription_device_limit, + ) + + return None + + async def handle_potential_referral_code( message: types.Message, state: FSMContext, @@ -86,13 +129,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") referral_code = None - if len(message.text.split()) > 1: - potential_code = message.text.split()[1] - referral_code = potential_code - logger.info(f"🔎 Найден реферальный код: {referral_code}") - + campaign = None + start_args = message.text.split() + if len(start_args) > 1: + start_parameter = start_args[1] + campaign = await get_campaign_by_start_parameter( + db, + start_parameter, + only_active=True, + ) + + if campaign: + logger.info( + "📣 Найдена рекламная кампания %s (start=%s)", + campaign.id, + campaign.start_parameter, + ) + await state.update_data(campaign_id=campaign.id) + else: + referral_code = start_parameter + logger.info(f"🔎 Найден реферальный код: {referral_code}") + if referral_code: - await state.set_data({'referral_code': referral_code}) + await state.update_data(referral_code=referral_code) user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) @@ -130,9 +189,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, await db.commit() texts = get_texts(user.language) - + if referral_code and not user.referred_by_id: - await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.") + await message.answer( + "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена." + ) + + if campaign: + try: + await message.answer(texts.CAMPAIGN_EXISTING_USER) + except Exception as e: + logger.error( + f"Ошибка отправки уведомления о рекламной кампании: {e}" + ) has_active_subscription = user.subscription is not None subscription_is_active = False @@ -533,9 +602,17 @@ async def complete_registration_from_callback( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + await state.clear() + if campaign_message: + try: + await callback.message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) @@ -698,9 +775,17 @@ async def complete_registration( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + await state.clear() + if campaign_message: + try: + await message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 067c1e83..d49daf82 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") ], + [ + InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] @@ -147,6 +150,54 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"), + InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create") + ], + [ + InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") + ] + ]) + + +def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup: + status_text = "🔴 Выключить" if is_active else "🟢 Включить" + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"), + InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}") + ], + [ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}") + ], + [ + InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list") + ] + ]) + + +def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"), + InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") + ] + ]) + + def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/localization/texts.py b/app/localization/texts.py index 4ad43c7c..2984f370 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -312,8 +312,15 @@ class RussianTexts(Texts): 💪 Быстро, надежно, недорого! """ - + CREATE_INVITE = "📝 Создать приглашение" + CAMPAIGN_EXISTING_USER = ( + "ℹ️ Эта рекламная ссылка доступна только новым пользователям." + ) + CAMPAIGN_BONUS_BALANCE = ( + "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!" + ) + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!" TRIAL_ENDING_SOON = """ 🎁 Тестовая подписка скоро закончится! @@ -421,6 +428,7 @@ class RussianTexts(Texts): ADMIN_USERS = "👥 Пользователи" ADMIN_SUBSCRIPTIONS = "📱 Подписки" ADMIN_PROMOCODES = "🎫 Промокоды" + ADMIN_CAMPAIGNS = "📣 Рекламные кампании" ADMIN_MESSAGES = "📨 Рассылки" ADMIN_MONITORING = "🔍 Мониторинг" ADMIN_REFERRALS = "🤝 Партнерка" @@ -535,6 +543,7 @@ To get started, select interface language: CONTINUE = "➡️ Continue" YES = "✅ Yes" NO = "❌ No" + ADMIN_CAMPAIGNS = "📣 Campaigns" MENU_BALANCE = "💰 Balance" MENU_SUBSCRIPTION = "📱 Subscription" @@ -545,6 +554,9 @@ To get started, select interface language: GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." + CAMPAIGN_EXISTING_USER = "ℹ️ This campaign link is available for new users only." + CAMPAIGN_BONUS_BALANCE = "🎉 You received {amount} for joining via campaign “{name}”!" + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 You received a {days}-day subscription (traffic: {traffic}, devices: {devices}) from campaign “{name}”!" LANGUAGES = { diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py new file mode 100644 index 00000000..8fecc497 --- /dev/null +++ b/app/services/campaign_service.py @@ -0,0 +1,171 @@ +import logging +from dataclasses import dataclass +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import record_campaign_registration +from app.database.crud.subscription import ( + create_paid_subscription, + get_subscription_by_user_id, +) +from app.database.crud.user import add_user_balance +from app.database.models import AdvertisingCampaign, User +from app.services.subscription_service import SubscriptionService + +logger = logging.getLogger(__name__) + + +@dataclass +class CampaignBonusResult: + success: bool + bonus_type: Optional[str] = None + balance_kopeks: int = 0 + subscription_days: Optional[int] = None + subscription_traffic_gb: Optional[int] = None + subscription_device_limit: Optional[int] = None + subscription_squads: Optional[List[str]] = None + + +class AdvertisingCampaignService: + def __init__(self) -> None: + self.subscription_service = SubscriptionService() + + async def apply_campaign_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + if not campaign.is_active: + logger.warning( + "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id + ) + return CampaignBonusResult(success=False) + + if campaign.is_balance_bonus: + return await self._apply_balance_bonus(db, user, campaign) + + if campaign.is_subscription_bonus: + return await self._apply_subscription_bonus(db, user, campaign) + + logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type) + return CampaignBonusResult(success=False) + + async def _apply_balance_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + amount = campaign.balance_bonus_kopeks or 0 + if amount <= 0: + logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id) + return CampaignBonusResult(success=False) + + description = f"Бонус за регистрацию по кампании '{campaign.name}'" + success = await add_user_balance( + db, + user, + amount, + description=description, + ) + + if not success: + return CampaignBonusResult(success=False) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="balance", + balance_bonus_kopeks=amount, + ) + + logger.info( + "💰 Пользователю %s начислен бонус %s₽ по кампании %s", + user.telegram_id, + amount / 100, + campaign.id, + ) + + return CampaignBonusResult( + success=True, + bonus_type="balance", + balance_kopeks=amount, + ) + + async def _apply_subscription_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + existing_subscription = await get_subscription_by_user_id(db, user.id) + if existing_subscription: + logger.warning( + "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен", + user.telegram_id, + campaign.id, + ) + return CampaignBonusResult(success=False) + + duration_days = campaign.subscription_duration_days or 0 + if duration_days <= 0: + logger.info( + "ℹ️ Кампания %s не содержит корректной длительности подписки", + campaign.id, + ) + return CampaignBonusResult(success=False) + + traffic_limit = campaign.subscription_traffic_gb + device_limit = ( + campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + ) + squads = list(campaign.subscription_squads or []) + + if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None): + squads = [settings.TRIAL_SQUAD_UUID] + + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error( + "❌ Ошибка синхронизации RemnaWave для кампании %s: %s", + campaign.id, + error, + ) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="subscription", + subscription_duration_days=duration_days, + ) + + logger.info( + "🎁 Пользователю %s выдана подписка по кампании %s на %s дней", + user.telegram_id, + campaign.id, + duration_days, + ) + + return CampaignBonusResult( + success=True, + bonus_type="subscription", + subscription_days=duration_days, + subscription_traffic_gb=traffic_limit or 0, + subscription_device_limit=device_limit, + subscription_squads=squads, + ) diff --git a/app/states.py b/app/states.py index b0479c08..35f461e0 100644 --- a/app/states.py +++ b/app/states.py @@ -41,6 +41,15 @@ class AdminStates(StatesGroup): setting_promocode_value = State() setting_promocode_uses = State() setting_promocode_expiry = State() + + creating_campaign_name = State() + creating_campaign_start = State() + creating_campaign_bonus = State() + creating_campaign_balance = State() + creating_campaign_subscription_days = State() + creating_campaign_subscription_traffic = State() + creating_campaign_subscription_devices = State() + creating_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py new file mode 100644 index 00000000..31a943b8 --- /dev/null +++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py @@ -0,0 +1,70 @@ +"""add advertising campaigns tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "5d1f1f8b2e9a" +down_revision: Union[str, None] = "cbd1be472f3d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "advertising_campaigns", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_parameter", sa.String(length=64), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), + sa.Column("subscription_device_limit", sa.Integer(), nullable=True), + sa.Column("subscription_squads", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index( + "ix_advertising_campaigns_start_parameter", + "advertising_campaigns", + ["start_parameter"], + unique=True, + ) + op.create_index( + "ix_advertising_campaigns_id", + "advertising_campaigns", + ["id"], + ) + + op.create_table( + "advertising_campaign_registrations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + op.create_index( + "ix_advertising_campaign_registrations_id", + "advertising_campaign_registrations", + ["id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations") + op.drop_table("advertising_campaign_registrations") + op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns") + op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns") + op.drop_table("advertising_campaigns") From 32da16f652bf9db4cc21061fc92deaf0560f3152 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 12:29:28 +0300 Subject: [PATCH 28/33] Refresh user state after campaign bonuses --- app/bot.py | 23 +- app/database/crud/campaign.py | 258 ++++++ app/database/models.py | 73 +- app/handlers/admin/campaigns.py | 805 ++++++++++++++++++ app/handlers/start.py | 159 +++- app/keyboards/admin.py | 53 +- app/localization/texts.py | 14 +- app/services/campaign_service.py | 171 ++++ app/states.py | 9 + .../5d1f1f8b2e9a_add_advertising_campaigns.py | 70 ++ 10 files changed, 1603 insertions(+), 32 deletions(-) create mode 100644 app/database/crud/campaign.py create mode 100644 app/handlers/admin/campaigns.py create mode 100644 app/services/campaign_service.py create mode 100644 migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py diff --git a/app/bot.py b/app/bot.py index aaa30c53..1be1fd65 100644 --- a/app/bot.py +++ b/app/bot.py @@ -19,15 +19,23 @@ from app.handlers import ( referral, support, common ) from app.handlers.admin import ( - main as admin_main, users as admin_users, subscriptions as admin_subscriptions, - promocodes as admin_promocodes, messages as admin_messages, - monitoring as admin_monitoring, referrals as admin_referrals, - rules as admin_rules, remnawave as admin_remnawave, - statistics as admin_statistics, servers as admin_servers, + main as admin_main, + users as admin_users, + subscriptions as admin_subscriptions, + promocodes as admin_promocodes, + messages as admin_messages, + monitoring as admin_monitoring, + referrals as admin_referrals, + rules as admin_rules, + remnawave as admin_remnawave, + statistics as admin_statistics, + servers as admin_servers, maintenance as admin_maintenance, + campaigns as admin_campaigns, user_messages as admin_user_messages, - updates as admin_updates, backup as admin_backup, - welcome_text as admin_welcome_text + updates as admin_updates, + backup as admin_backup, + welcome_text as admin_welcome_text, ) from app.handlers.stars_payments import register_stars_handlers @@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_campaigns.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) admin_updates.register_handlers(dp) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py new file mode 100644 index 00000000..40313434 --- /dev/null +++ b/app/database/crud/campaign.py @@ -0,0 +1,258 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from sqlalchemy import and_, func, select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + AdvertisingCampaign, + AdvertisingCampaignRegistration, +) + +logger = logging.getLogger(__name__) + + +async def create_campaign( + db: AsyncSession, + *, + name: str, + start_parameter: str, + bonus_type: str, + created_by: Optional[int] = None, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, + subscription_traffic_gb: Optional[int] = None, + subscription_device_limit: Optional[int] = None, + subscription_squads: Optional[List[str]] = None, +) -> AdvertisingCampaign: + campaign = AdvertisingCampaign( + name=name, + start_parameter=start_parameter, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + subscription_traffic_gb=subscription_traffic_gb, + subscription_device_limit=subscription_device_limit, + subscription_squads=subscription_squads or [], + created_by=created_by, + is_active=True, + ) + + db.add(campaign) + await db.commit() + await db.refresh(campaign) + + logger.info( + "📣 Создана рекламная кампания %s (start=%s, bonus=%s)", + campaign.name, + campaign.start_parameter, + campaign.bonus_type, + ) + return campaign + + +async def get_campaign_by_id( + db: AsyncSession, campaign_id: int +) -> Optional[AdvertisingCampaign]: + result = await db.execute( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .where(AdvertisingCampaign.id == campaign_id) + ) + return result.scalar_one_or_none() + + +async def get_campaign_by_start_parameter( + db: AsyncSession, + start_parameter: str, + *, + only_active: bool = False, +) -> Optional[AdvertisingCampaign]: + stmt = select(AdvertisingCampaign).where( + AdvertisingCampaign.start_parameter == start_parameter + ) + if only_active: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def get_campaigns_list( + db: AsyncSession, + *, + offset: int = 0, + limit: int = 20, + include_inactive: bool = True, +) -> List[AdvertisingCampaign]: + stmt = ( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .order_by(AdvertisingCampaign.created_at.desc()) + .offset(offset) + .limit(limit) + ) + if not include_inactive: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_campaigns_count( + db: AsyncSession, *, is_active: Optional[bool] = None +) -> int: + stmt = select(func.count(AdvertisingCampaign.id)) + if is_active is not None: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active)) + + result = await db.execute(stmt) + return result.scalar_one() or 0 + + +async def update_campaign( + db: AsyncSession, + campaign: AdvertisingCampaign, + **kwargs, +) -> AdvertisingCampaign: + allowed_fields = { + "name", + "start_parameter", + "bonus_type", + "balance_bonus_kopeks", + "subscription_duration_days", + "subscription_traffic_gb", + "subscription_device_limit", + "subscription_squads", + "is_active", + } + + update_data = {key: value for key, value in kwargs.items() if key in allowed_fields} + + if not update_data: + return campaign + + update_data["updated_at"] = datetime.utcnow() + + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.id == campaign.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(campaign) + + logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data) + return campaign + + +async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: + await db.execute( + delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id) + ) + await db.commit() + logger.info("🗑️ Удалена рекламная кампания %s", campaign.name) + return True + + +async def record_campaign_registration( + db: AsyncSession, + *, + campaign_id: int, + user_id: int, + bonus_type: str, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, +) -> AdvertisingCampaignRegistration: + existing = await db.execute( + select(AdvertisingCampaignRegistration).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.user_id == user_id, + ) + ) + ) + registration = existing.scalar_one_or_none() + if registration: + return registration + + registration = AdvertisingCampaignRegistration( + campaign_id=campaign_id, + user_id=user_id, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + ) + db.add(registration) + await db.commit() + await db.refresh(registration) + + logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id) + return registration + + +async def get_campaign_statistics( + db: AsyncSession, + campaign_id: int, +) -> Dict[str, Optional[int]]: + result = await db.execute( + select( + func.count(AdvertisingCampaignRegistration.id), + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ), + func.max(AdvertisingCampaignRegistration.created_at), + ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + count, total_balance, last_registration = result.one() + + subscription_count_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.bonus_type == "subscription", + ) + ) + ) + + return { + "registrations": count or 0, + "balance_issued": total_balance or 0, + "subscription_issued": subscription_count_result.scalar() or 0, + "last_registration": last_registration, + } + + +async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: + total = await get_campaigns_count(db) + active = await get_campaigns_count(db, is_active=True) + inactive = await get_campaigns_count(db, is_active=False) + + registrations_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)) + ) + + balance_result = await db.execute( + select( + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ) + ) + ) + + subscription_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + AdvertisingCampaignRegistration.bonus_type == "subscription" + ) + ) + + return { + "total": total, + "active": active, + "inactive": inactive, + "registrations": registrations_result.scalar() or 0, + "balance_total": balance_result.scalar() or 0, + "subscription_total": subscription_result.scalar() or 0, + } diff --git a/app/database/models.py b/app/database/models.py index e12e1eb7..285eeb08 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3,8 +3,17 @@ from typing import Optional, List from enum import Enum from sqlalchemy import ( - Column, Integer, String, DateTime, Boolean, Text, - ForeignKey, Float, JSON, BigInteger + Column, + Integer, + String, + DateTime, + Boolean, + Text, + ForeignKey, + Float, + JSON, + BigInteger, + UniqueConstraint, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -666,7 +675,7 @@ class UserMessage(Base): class WelcomeText(Base): __tablename__ = "welcome_texts" - + id = Column(Integer, primary_key=True, index=True) text_content = Column(Text, nullable=False) is_active = Column(Boolean, default=True) @@ -674,5 +683,61 @@ class WelcomeText(Base): created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + creator = relationship("User", backref="created_welcome_texts") + + +class AdvertisingCampaign(Base): + __tablename__ = "advertising_campaigns" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + start_parameter = Column(String(64), nullable=False, unique=True, index=True) + bonus_type = Column(String(20), nullable=False) + + balance_bonus_kopeks = Column(Integer, default=0) + + subscription_duration_days = Column(Integer, nullable=True) + subscription_traffic_gb = Column(Integer, nullable=True) + subscription_device_limit = Column(Integer, nullable=True) + subscription_squads = Column(JSON, default=list) + + is_active = Column(Boolean, default=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign") + + @property + def is_balance_bonus(self) -> bool: + return self.bonus_type == "balance" + + @property + def is_subscription_bonus(self) -> bool: + return self.bonus_type == "subscription" + + +class AdvertisingCampaignRegistration(Base): + __tablename__ = "advertising_campaign_registrations" + __table_args__ = ( + UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + bonus_type = Column(String(20), nullable=False) + balance_bonus_kopeks = Column(Integer, default=0) + subscription_duration_days = Column(Integer, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + campaign = relationship("AdvertisingCampaign", back_populates="registrations") + user = relationship("User") + + @property + def balance_bonus_rubles(self) -> float: + return (self.balance_bonus_kopeks or 0) / 100 diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py new file mode 100644 index 00000000..68708b67 --- /dev/null +++ b/app/handlers/admin/campaigns.py @@ -0,0 +1,805 @@ +import logging +import re +from typing import List + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import ( + create_campaign, + delete_campaign, + get_campaign_by_id, + get_campaign_by_start_parameter, + get_campaign_statistics, + get_campaigns_count, + get_campaigns_list, + get_campaigns_overview, + update_campaign, +) +from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id +from app.database.models import User +from app.keyboards.admin import ( + get_admin_campaigns_keyboard, + get_admin_pagination_keyboard, + get_campaign_bonus_type_keyboard, + get_campaign_management_keyboard, + get_confirmation_keyboard, +) +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") +_CAMPAIGNS_PAGE_SIZE = 5 + + +def _format_campaign_summary(campaign, texts) -> str: + status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" + + if campaign.is_balance_bonus: + bonus_text = texts.format_price(campaign.balance_bonus_kopeks) + bonus_info = f"💰 Бонус на баланс: {bonus_text}" + else: + traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) + bonus_info = ( + "📱 Подписка: {days} д.\n" + "🌐 Трафик: {traffic}\n" + "📱 Устройства: {devices}" + ).format( + days=campaign.subscription_duration_days or 0, + traffic=traffic_text, + devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, + ) + + return ( + f"{campaign.name}\n" + f"Стартовый параметр: {campaign.start_parameter}\n" + f"Статус: {status}\n" + f"{bonus_info}\n" + ) + + +async def _get_bot_deep_link( + callback: types.CallbackQuery, start_parameter: str +) -> str: + bot = await callback.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +async def _get_bot_deep_link_from_message( + message: types.Message, start_parameter: str +) -> str: + bot = await message.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +def _build_campaign_servers_keyboard( + servers, selected_uuids: List[str] +) -> types.InlineKeyboardMarkup: + keyboard: List[List[types.InlineKeyboardButton]] = [] + + for server in servers[:20]: + is_selected = server.squad_uuid in selected_uuids + emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") + text = f"{emoji} {server.display_name}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=text, callback_data=f"campaign_toggle_server_{server.id}" + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="✅ Сохранить", callback_data="campaign_servers_save" + ), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"), + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +@admin_required +@error_handler +async def show_campaigns_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ( + "📣 Рекламные кампании\n\n" + f"Всего кампаний: {overview['total']}\n" + f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" + f"Регистраций: {overview['registrations']}\n" + f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" + f"Выдано подписок: {overview['subscription_total']}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_overall_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ["📊 Общая статистика кампаний\n"] + text.append(f"Всего кампаний: {overview['total']}") + text.append( + f"Активны: {overview['active']}, выключены: {overview['inactive']}" + ) + text.append(f"Всего регистраций: {overview['registrations']}") + text.append( + f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" + ) + text.append(f"Выдано подписок: {overview['subscription_total']}") + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + page = 1 + if callback.data.startswith("admin_campaigns_list_page_"): + try: + page = int(callback.data.split("_")[-1]) + except ValueError: + page = 1 + + offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE + campaigns = await get_campaigns_list( + db, + offset=offset, + limit=_CAMPAIGNS_PAGE_SIZE, + ) + total = await get_campaigns_count(db) + total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE) + + if not campaigns: + await callback.message.edit_text( + "❌ Рекламные кампании не найдены.", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="➕ Создать", callback_data="admin_campaigns_create" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ], + ] + ), + ) + await callback.answer() + return + + text_lines = ["📋 Список кампаний\n"] + + for campaign in campaigns: + registrations = len(campaign.registrations or []) + total_balance = sum( + r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] + ) + status = "🟢" if campaign.is_active else "⚪" + line = ( + f"{status} {campaign.name}{campaign.start_parameter}\n" + f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" + ) + if campaign.is_subscription_bonus: + line += f", подписка: {campaign.subscription_duration_days or 0} д." + else: + line += ", бонус: баланс" + text_lines.append(line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=f"🔍 {campaign.name}", + callback_data=f"admin_campaign_manage_{campaign.id}", + ) + ] + for campaign in campaigns + ] + + pagination = get_admin_pagination_keyboard( + current_page=page, + total_pages=total_pages, + callback_prefix="admin_campaigns_list", + back_callback="admin_campaigns", + language=db_user.language, + ) + + keyboard_rows.extend(pagination.inline_keyboard) + + await callback.message.edit_text( + "\n".join(text_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaign_detail( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + + text = ["📣 Управление кампанией\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"🔗 Ссылка: {deep_link}") + text.append("\n📊 Статистика") + text.append(f"• Регистраций: {stats['registrations']}") + text.append( + f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" + ) + text.append(f"• Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_campaign_status( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + new_status = not campaign.is_active + await update_campaign(db, campaign, is_active=new_status) + status_text = "включена" if new_status else "выключена" + logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) + + await show_campaign_detail(callback, db_user, db) + + +@admin_required +@error_handler +async def show_campaign_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + + text = ["📊 Статистика кампании\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"Регистраций: {stats['registrations']}") + text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") + text.append(f"Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_campaign_manage_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_delete_campaign( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + text = ( + "🗑️ Удаление кампании\n\n" + f"Название: {campaign.name}\n" + f"Параметр: {campaign.start_parameter}\n\n" + "Вы уверены, что хотите удалить кампанию?" + ) + + await callback.message.edit_text( + text, + reply_markup=get_confirmation_keyboard( + confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_callback=f"admin_campaign_manage_{campaign_id}", + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_campaign_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await delete_campaign(db, campaign) + await callback.message.edit_text( + "✅ Кампания удалена.", + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer("Удалено") + + +@admin_required +@error_handler +async def start_campaign_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + await callback.message.edit_text( + "🆕 Создание рекламной кампании\n\nВведите название кампании:", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await state.set_state(AdminStates.creating_campaign_name) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + name = message.text.strip() + if len(name) < 3 or len(name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + await state.update_data(campaign_name=name) + await state.set_state(AdminStates.creating_campaign_start) + await message.answer( + "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", + ) + + +@admin_required +@error_handler +async def process_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + start_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(start_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + existing = await get_campaign_by_start_parameter(db, start_param) + if existing: + await message.answer( + "❌ Кампания с таким параметром уже существует. Введите другой параметр." + ) + return + + await state.update_data(campaign_start_parameter=start_param) + await state.set_state(AdminStates.creating_campaign_bonus) + await message.answer( + "🎯 Выберите тип бонуса для кампании:", + reply_markup=get_campaign_bonus_type_keyboard(db_user.language), + ) + + +@admin_required +@error_handler +async def select_campaign_bonus_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + bonus_type = "balance" if callback.data.endswith("balance") else "subscription" + await state.update_data(campaign_bonus_type=bonus_type) + + if bonus_type == "balance": + await state.set_state(AdminStates.creating_campaign_balance) + await callback.message.edit_text( + "💰 Введите сумму бонуса на баланс (в рублях):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + else: + await state.set_state(AdminStates.creating_campaign_subscription_days) + await callback.message.edit_text( + "📅 Введите длительность подписки в днях (1-730):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_balance_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + data = await state.get_data() + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="balance", + balance_bonus_kopeks=amount_kopeks, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await message.answer( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + await state.update_data(campaign_subscription_days=days) + await state.set_state(AdminStates.creating_campaign_subscription_traffic) + await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") + + +@admin_required +@error_handler +async def process_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + await state.update_data(campaign_subscription_traffic=traffic) + await state.set_state(AdminStates.creating_campaign_subscription_devices) + await message.answer( + f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + await state.update_data(campaign_subscription_devices=devices) + await state.update_data(campaign_subscription_squads=[]) + await state.set_state(AdminStates.creating_campaign_subscription_servers) + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await message.answer( + "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", + ) + await state.clear() + return + + keyboard = _build_campaign_servers_keyboard(servers, []) + await message.answer( + "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def toggle_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + server_id = int(callback.data.split("_")[-1]) + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + data = await state.get_data() + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard(servers, selected) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def finalize_campaign_subscription( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + selected = data.get("campaign_subscription_squads", []) + + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="subscription", + subscription_duration_days=data.get("campaign_subscription_days"), + subscription_traffic_gb=data.get("campaign_subscription_traffic"), + subscription_device_limit=data.get("campaign_subscription_devices"), + subscription_squads=selected, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") + dp.callback_query.register( + show_campaigns_overall_stats, F.data == "admin_campaigns_stats" + ) + dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") + dp.callback_query.register( + show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") + ) + dp.callback_query.register( + start_campaign_creation, F.data == "admin_campaigns_create" + ) + dp.callback_query.register( + show_campaign_stats, F.data.startswith("admin_campaign_stats_") + ) + dp.callback_query.register( + show_campaign_detail, F.data.startswith("admin_campaign_manage_") + ) + dp.callback_query.register( + delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") + ) + dp.callback_query.register( + confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") + ) + dp.callback_query.register( + toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") + ) + dp.callback_query.register( + finalize_campaign_subscription, F.data == "campaign_servers_save" + ) + dp.callback_query.register( + toggle_campaign_server, F.data.startswith("campaign_toggle_server_") + ) + dp.callback_query.register( + select_campaign_bonus_type, F.data.startswith("campaign_bonus_") + ) + + dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) + dp.message.register( + process_campaign_start_parameter, AdminStates.creating_campaign_start + ) + dp.message.register( + process_campaign_balance_value, AdminStates.creating_campaign_balance + ) + dp.message.register( + process_campaign_subscription_days, + AdminStates.creating_campaign_subscription_days, + ) + dp.message.register( + process_campaign_subscription_traffic, + AdminStates.creating_campaign_subscription_traffic, + ) + dp.message.register( + process_campaign_subscription_devices, + AdminStates.creating_campaign_subscription_devices, + ) diff --git a/app/handlers/start.py b/app/handlers/start.py index b33ced5f..312010df 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import RegistrationStates from app.database.crud.user import ( - get_user_by_telegram_id, create_user, get_user_by_referral_code + get_user_by_telegram_id, + create_user, + get_user_by_referral_code, +) +from app.database.crud.campaign import ( + get_campaign_by_start_parameter, + get_campaign_by_id, ) from app.database.models import UserStatus from app.keyboards.inline import ( @@ -17,15 +23,52 @@ from app.keyboards.inline import ( ) from app.localization.texts import get_texts from app.services.referral_service import process_referral_registration +from app.services.campaign_service import AdvertisingCampaignService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message -from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest logger = logging.getLogger(__name__) +async def _apply_campaign_bonus_if_needed( + db: AsyncSession, + user, + state_data: dict, + texts, +): + campaign_id = state_data.get("campaign_id") if state_data else None + if not campaign_id: + return None + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign or not campaign.is_active: + return None + + service = AdvertisingCampaignService() + result = await service.apply_campaign_bonus(db, user, campaign) + if not result.success: + return None + + if result.bonus_type == "balance": + amount_text = texts.format_price(result.balance_kopeks) + return texts.CAMPAIGN_BONUS_BALANCE.format( + amount=amount_text, + name=campaign.name, + ) + + if result.bonus_type == "subscription": + traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) + return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( + name=campaign.name, + days=result.subscription_days, + traffic=traffic_text, + devices=result.subscription_device_limit, + ) + + return None + + async def handle_potential_referral_code( message: types.Message, state: FSMContext, @@ -86,13 +129,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") referral_code = None - if len(message.text.split()) > 1: - potential_code = message.text.split()[1] - referral_code = potential_code - logger.info(f"🔎 Найден реферальный код: {referral_code}") - + campaign = None + start_args = message.text.split() + if len(start_args) > 1: + start_parameter = start_args[1] + campaign = await get_campaign_by_start_parameter( + db, + start_parameter, + only_active=True, + ) + + if campaign: + logger.info( + "📣 Найдена рекламная кампания %s (start=%s)", + campaign.id, + campaign.start_parameter, + ) + await state.update_data(campaign_id=campaign.id) + else: + referral_code = start_parameter + logger.info(f"🔎 Найден реферальный код: {referral_code}") + if referral_code: - await state.set_data({'referral_code': referral_code}) + await state.update_data(referral_code=referral_code) user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) @@ -130,9 +189,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, await db.commit() texts = get_texts(user.language) - + if referral_code and not user.referred_by_id: - await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.") + await message.answer( + "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена." + ) + + if campaign: + try: + await message.answer(texts.CAMPAIGN_EXISTING_USER) + except Exception as e: + logger.error( + f"Ошибка отправки уведомления о рекламной кампании: {e}" + ) has_active_subscription = user.subscription is not None subscription_is_active = False @@ -533,9 +602,35 @@ async def complete_registration_from_callback( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await callback.message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) @@ -551,10 +646,10 @@ async def complete_registration_from_callback( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) @@ -698,9 +793,35 @@ async def complete_registration( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) @@ -716,10 +837,10 @@ async def complete_registration( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 067c1e83..d49daf82 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") ], + [ + InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] @@ -147,6 +150,54 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"), + InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create") + ], + [ + InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") + ] + ]) + + +def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup: + status_text = "🔴 Выключить" if is_active else "🟢 Включить" + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"), + InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}") + ], + [ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}") + ], + [ + InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list") + ] + ]) + + +def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"), + InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") + ] + ]) + + def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/localization/texts.py b/app/localization/texts.py index 4ad43c7c..2984f370 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -312,8 +312,15 @@ class RussianTexts(Texts): 💪 Быстро, надежно, недорого! """ - + CREATE_INVITE = "📝 Создать приглашение" + CAMPAIGN_EXISTING_USER = ( + "ℹ️ Эта рекламная ссылка доступна только новым пользователям." + ) + CAMPAIGN_BONUS_BALANCE = ( + "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!" + ) + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!" TRIAL_ENDING_SOON = """ 🎁 Тестовая подписка скоро закончится! @@ -421,6 +428,7 @@ class RussianTexts(Texts): ADMIN_USERS = "👥 Пользователи" ADMIN_SUBSCRIPTIONS = "📱 Подписки" ADMIN_PROMOCODES = "🎫 Промокоды" + ADMIN_CAMPAIGNS = "📣 Рекламные кампании" ADMIN_MESSAGES = "📨 Рассылки" ADMIN_MONITORING = "🔍 Мониторинг" ADMIN_REFERRALS = "🤝 Партнерка" @@ -535,6 +543,7 @@ To get started, select interface language: CONTINUE = "➡️ Continue" YES = "✅ Yes" NO = "❌ No" + ADMIN_CAMPAIGNS = "📣 Campaigns" MENU_BALANCE = "💰 Balance" MENU_SUBSCRIPTION = "📱 Subscription" @@ -545,6 +554,9 @@ To get started, select interface language: GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." + CAMPAIGN_EXISTING_USER = "ℹ️ This campaign link is available for new users only." + CAMPAIGN_BONUS_BALANCE = "🎉 You received {amount} for joining via campaign “{name}”!" + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 You received a {days}-day subscription (traffic: {traffic}, devices: {devices}) from campaign “{name}”!" LANGUAGES = { diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py new file mode 100644 index 00000000..8fecc497 --- /dev/null +++ b/app/services/campaign_service.py @@ -0,0 +1,171 @@ +import logging +from dataclasses import dataclass +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import record_campaign_registration +from app.database.crud.subscription import ( + create_paid_subscription, + get_subscription_by_user_id, +) +from app.database.crud.user import add_user_balance +from app.database.models import AdvertisingCampaign, User +from app.services.subscription_service import SubscriptionService + +logger = logging.getLogger(__name__) + + +@dataclass +class CampaignBonusResult: + success: bool + bonus_type: Optional[str] = None + balance_kopeks: int = 0 + subscription_days: Optional[int] = None + subscription_traffic_gb: Optional[int] = None + subscription_device_limit: Optional[int] = None + subscription_squads: Optional[List[str]] = None + + +class AdvertisingCampaignService: + def __init__(self) -> None: + self.subscription_service = SubscriptionService() + + async def apply_campaign_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + if not campaign.is_active: + logger.warning( + "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id + ) + return CampaignBonusResult(success=False) + + if campaign.is_balance_bonus: + return await self._apply_balance_bonus(db, user, campaign) + + if campaign.is_subscription_bonus: + return await self._apply_subscription_bonus(db, user, campaign) + + logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type) + return CampaignBonusResult(success=False) + + async def _apply_balance_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + amount = campaign.balance_bonus_kopeks or 0 + if amount <= 0: + logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id) + return CampaignBonusResult(success=False) + + description = f"Бонус за регистрацию по кампании '{campaign.name}'" + success = await add_user_balance( + db, + user, + amount, + description=description, + ) + + if not success: + return CampaignBonusResult(success=False) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="balance", + balance_bonus_kopeks=amount, + ) + + logger.info( + "💰 Пользователю %s начислен бонус %s₽ по кампании %s", + user.telegram_id, + amount / 100, + campaign.id, + ) + + return CampaignBonusResult( + success=True, + bonus_type="balance", + balance_kopeks=amount, + ) + + async def _apply_subscription_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + existing_subscription = await get_subscription_by_user_id(db, user.id) + if existing_subscription: + logger.warning( + "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен", + user.telegram_id, + campaign.id, + ) + return CampaignBonusResult(success=False) + + duration_days = campaign.subscription_duration_days or 0 + if duration_days <= 0: + logger.info( + "ℹ️ Кампания %s не содержит корректной длительности подписки", + campaign.id, + ) + return CampaignBonusResult(success=False) + + traffic_limit = campaign.subscription_traffic_gb + device_limit = ( + campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + ) + squads = list(campaign.subscription_squads or []) + + if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None): + squads = [settings.TRIAL_SQUAD_UUID] + + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error( + "❌ Ошибка синхронизации RemnaWave для кампании %s: %s", + campaign.id, + error, + ) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="subscription", + subscription_duration_days=duration_days, + ) + + logger.info( + "🎁 Пользователю %s выдана подписка по кампании %s на %s дней", + user.telegram_id, + campaign.id, + duration_days, + ) + + return CampaignBonusResult( + success=True, + bonus_type="subscription", + subscription_days=duration_days, + subscription_traffic_gb=traffic_limit or 0, + subscription_device_limit=device_limit, + subscription_squads=squads, + ) diff --git a/app/states.py b/app/states.py index b0479c08..35f461e0 100644 --- a/app/states.py +++ b/app/states.py @@ -41,6 +41,15 @@ class AdminStates(StatesGroup): setting_promocode_value = State() setting_promocode_uses = State() setting_promocode_expiry = State() + + creating_campaign_name = State() + creating_campaign_start = State() + creating_campaign_bonus = State() + creating_campaign_balance = State() + creating_campaign_subscription_days = State() + creating_campaign_subscription_traffic = State() + creating_campaign_subscription_devices = State() + creating_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py new file mode 100644 index 00000000..31a943b8 --- /dev/null +++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py @@ -0,0 +1,70 @@ +"""add advertising campaigns tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "5d1f1f8b2e9a" +down_revision: Union[str, None] = "cbd1be472f3d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "advertising_campaigns", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_parameter", sa.String(length=64), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), + sa.Column("subscription_device_limit", sa.Integer(), nullable=True), + sa.Column("subscription_squads", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index( + "ix_advertising_campaigns_start_parameter", + "advertising_campaigns", + ["start_parameter"], + unique=True, + ) + op.create_index( + "ix_advertising_campaigns_id", + "advertising_campaigns", + ["id"], + ) + + op.create_table( + "advertising_campaign_registrations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + op.create_index( + "ix_advertising_campaign_registrations_id", + "advertising_campaign_registrations", + ["id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations") + op.drop_table("advertising_campaign_registrations") + op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns") + op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns") + op.drop_table("advertising_campaigns") From cf9fbc5ccc4a205e0bde8a7c999be82f5af952f9 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 13:15:17 +0300 Subject: [PATCH 29/33] Fix campaign deletion confirmation and add editing workflow --- app/handlers/admin/campaigns.py | 864 +++++++++++++++++++++++++++++++- app/keyboards/admin.py | 112 ++++- app/states.py | 8 + 3 files changed, 965 insertions(+), 19 deletions(-) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 68708b67..482221fa 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -2,7 +2,7 @@ import logging import re from typing import List -from aiogram import Dispatcher, types, F +from aiogram import Bot, Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +24,7 @@ from app.keyboards.admin import ( get_admin_campaigns_keyboard, get_admin_pagination_keyboard, get_campaign_bonus_type_keyboard, + get_campaign_edit_keyboard, get_campaign_management_keyboard, get_confirmation_keyboard, ) @@ -78,7 +79,12 @@ async def _get_bot_deep_link_from_message( def _build_campaign_servers_keyboard( - servers, selected_uuids: List[str] + servers, + selected_uuids: List[str], + *, + toggle_prefix: str = "campaign_toggle_server_", + save_callback: str = "campaign_servers_save", + back_callback: str = "admin_campaigns", ) -> types.InlineKeyboardMarkup: keyboard: List[List[types.InlineKeyboardButton]] = [] @@ -89,7 +95,7 @@ def _build_campaign_servers_keyboard( keyboard.append( [ types.InlineKeyboardButton( - text=text, callback_data=f"campaign_toggle_server_{server.id}" + text=text, callback_data=f"{toggle_prefix}{server.id}" ) ] ) @@ -97,15 +103,44 @@ def _build_campaign_servers_keyboard( keyboard.append( [ types.InlineKeyboardButton( - text="✅ Сохранить", callback_data="campaign_servers_save" + text="✅ Сохранить", callback_data=save_callback + ), + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=back_callback ), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"), ] ) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) +async def _render_campaign_edit_menu( + bot: Bot, + chat_id: int, + message_id: int, + campaign, + language: str, +): + texts = get_texts(language) + text = ( + "✏️ Редактирование кампании\n\n" + f"{_format_campaign_summary(campaign, texts)}\n" + "Выберите, что изменить:" + ) + + await bot.edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + reply_markup=get_campaign_edit_keyboard( + campaign.id, + is_balance_bonus=campaign.is_balance_bonus, + language=language, + ), + parse_mode="HTML", + ) + + @admin_required @error_handler async def show_campaigns_menu( @@ -300,6 +335,761 @@ async def show_campaign_detail( await callback.answer() +@admin_required +@error_handler +async def show_campaign_edit_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_campaign_name( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_name) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "✏️ Изменение названия кампании\n\n" + f"Текущее название: {campaign.name}\n" + "Введите новое название (3-100 символов):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_name = message.text.strip() + if len(new_name) < 3 or len(new_name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + await update_campaign(db, campaign, name=new_name) + await state.clear() + + await message.answer("✅ Название обновлено.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_start_parameter( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_start) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "🔗 Изменение стартового параметра\n\n" + f"Текущий параметр: {campaign.start_parameter}\n" + "Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(new_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + existing = await get_campaign_by_start_parameter(db, new_param) + if existing and existing.id != campaign_id: + await message.answer("❌ Такой параметр уже используется. Введите другой вариант.") + return + + await update_campaign(db, campaign, start_parameter=new_param) + await state.clear() + + await message.answer("✅ Стартовый параметр обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_balance_bonus( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not campaign.is_balance_bonus: + await callback.answer("❌ У кампании другой тип бонуса", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_balance) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "💰 Изменение бонуса на баланс\n\n" + f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n" + "Введите новую сумму в рублях (например, 100 или 99.5):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_balance_bonus( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not campaign.is_balance_bonus: + await message.answer("❌ У кампании другой тип бонуса") + await state.clear() + return + + await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks) + await state.clear() + + await message.answer("✅ Бонус обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool: + if campaign.is_balance_bonus: + if isinstance(message_or_callback, types.CallbackQuery): + await message_or_callback.answer( + "❌ Для этой кампании доступен только бонус на баланс", + show_alert=True, + ) + else: + await message_or_callback.answer( + "❌ Для этой кампании нельзя изменить параметры подписки" + ) + return False + return True + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_days( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_days) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "📅 Изменение длительности подписки\n\n" + f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n" + "Введите новое количество дней (1-730):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_duration_days=days) + await state.clear() + + await message.answer("✅ Длительность подписки обновлена.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_traffic( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_traffic) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + current_traffic = campaign.subscription_traffic_gb or 0 + traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ" + + await callback.message.edit_text( + ( + "🌐 Изменение лимита трафика\n\n" + f"Текущее значение: {traffic_text}\n" + "Введите новый лимит в ГБ (0 = безлимит, максимум 10000):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_traffic_gb=traffic) + await state.clear() + + await message.answer("✅ Лимит трафика обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_devices( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_devices) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + + await callback.message.edit_text( + ( + "📱 Изменение лимита устройств\n\n" + f"Текущее значение: {current_devices}\n" + f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_device_limit=devices) + await state.clear() + + await message.answer("✅ Лимит устройств обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await callback.answer( + "❌ Не найдены доступные серверы. Добавьте серверы перед изменением.", + show_alert=True, + ) + return + + selected = list(campaign.subscription_squads or []) + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_servers) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_subscription_squads=selected, + ) + + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_text( + ( + "🌍 Редактирование доступных серверов\n\n" + "Нажмите на сервер, чтобы добавить или убрать его из кампании.\n" + "После выбора нажмите \"✅ Сохранить\"." + ), + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_edit_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + try: + server_id = int(parts[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Не удалось определить сервер", show_alert=True) + return + + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def save_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + selected = list(data.get("campaign_subscription_squads", [])) + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_squads=selected) + await state.clear() + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + ) + await callback.answer("✅ Сохранено") + + @admin_required @error_handler async def toggle_campaign_status( @@ -386,8 +1176,8 @@ async def confirm_delete_campaign( await callback.message.edit_text( text, reply_markup=get_confirmation_keyboard( - confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}", - cancel_callback=f"admin_campaign_manage_{campaign_id}", + confirm_action=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_action=f"admin_campaign_manage_{campaign_id}", ), ) await callback.answer() @@ -765,6 +1555,43 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( show_campaign_detail, F.data.startswith("admin_campaign_manage_") ) + dp.callback_query.register( + start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_") + ) + dp.callback_query.register( + start_edit_campaign_start_parameter, + F.data.startswith("admin_campaign_edit_start_"), + ) + dp.callback_query.register( + start_edit_campaign_balance_bonus, + F.data.startswith("admin_campaign_edit_balance_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_days, + F.data.startswith("admin_campaign_edit_sub_days_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_traffic, + F.data.startswith("admin_campaign_edit_sub_traffic_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_devices, + F.data.startswith("admin_campaign_edit_sub_devices_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_servers, + F.data.startswith("admin_campaign_edit_sub_servers_"), + ) + dp.callback_query.register( + save_edit_campaign_subscription_servers, + F.data.startswith("campaign_edit_servers_save_"), + ) + dp.callback_query.register( + toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_") + ) + dp.callback_query.register( + show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_") + ) dp.callback_query.register( delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") ) @@ -803,3 +1630,26 @@ def register_handlers(dp: Dispatcher): process_campaign_subscription_devices, AdminStates.creating_campaign_subscription_devices, ) + dp.message.register( + process_edit_campaign_name, AdminStates.editing_campaign_name + ) + dp.message.register( + process_edit_campaign_start_parameter, + AdminStates.editing_campaign_start, + ) + dp.message.register( + process_edit_campaign_balance_bonus, + AdminStates.editing_campaign_balance, + ) + dp.message.register( + process_edit_campaign_subscription_days, + AdminStates.editing_campaign_subscription_days, + ) + dp.message.register( + process_edit_campaign_subscription_traffic, + AdminStates.editing_campaign_subscription_traffic, + ) + dp.message.register( + process_edit_campaign_subscription_devices, + AdminStates.editing_campaign_subscription_devices, + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index d49daf82..3ecee4a5 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -167,21 +167,109 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup: +def get_campaign_management_keyboard( + campaign_id: int, is_active: bool, language: str = "ru" +) -> InlineKeyboardMarkup: status_text = "🔴 Выключить" if is_active else "🟢 Включить" - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"), - InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}") - ], - [ - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}") - ], - [ - InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📊 Статистика", + callback_data=f"admin_campaign_stats_{campaign_id}", + ), + InlineKeyboardButton( + text=status_text, + callback_data=f"admin_campaign_toggle_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="✏️ Редактировать", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"admin_campaign_delete_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="⬅️ К списку", callback_data="admin_campaigns_list" + ) + ], ] - ]) + ) + + +def get_campaign_edit_keyboard( + campaign_id: int, + *, + is_balance_bonus: bool, + language: str = "ru", +) -> InlineKeyboardMarkup: + texts = get_texts(language) + + keyboard: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text="✏️ Название", + callback_data=f"admin_campaign_edit_name_{campaign_id}", + ), + InlineKeyboardButton( + text="🔗 Параметр", + callback_data=f"admin_campaign_edit_start_{campaign_id}", + ), + ] + ] + + if is_balance_bonus: + keyboard.append( + [ + InlineKeyboardButton( + text="💰 Бонус на баланс", + callback_data=f"admin_campaign_edit_balance_{campaign_id}", + ) + ] + ) + else: + keyboard.extend( + [ + [ + InlineKeyboardButton( + text="📅 Длительность", + callback_data=f"admin_campaign_edit_sub_days_{campaign_id}", + ), + InlineKeyboardButton( + text="🌐 Трафик", + callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="📱 Устройства", + callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}", + ), + InlineKeyboardButton( + text="🌍 Серверы", + callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}", + ), + ], + ] + ) + + keyboard.append( + [ + InlineKeyboardButton( + text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}" + ) + ] + ) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: diff --git a/app/states.py b/app/states.py index 35f461e0..ac1f76f7 100644 --- a/app/states.py +++ b/app/states.py @@ -50,6 +50,14 @@ class AdminStates(StatesGroup): creating_campaign_subscription_traffic = State() creating_campaign_subscription_devices = State() creating_campaign_subscription_servers = State() + + editing_campaign_name = State() + editing_campaign_start = State() + editing_campaign_balance = State() + editing_campaign_subscription_days = State() + editing_campaign_subscription_traffic = State() + editing_campaign_subscription_devices = State() + editing_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() From 509c92217fd0a36eae57300236ff5f8675f9309d Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 13:23:30 +0300 Subject: [PATCH 30/33] Fix campaign edit menu fallback for non-text messages --- app/handlers/admin/campaigns.py | 906 +++++++++++++++++++++++++++++++- app/keyboards/admin.py | 112 +++- app/states.py | 8 + 3 files changed, 1006 insertions(+), 20 deletions(-) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 68708b67..67d204fc 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -1,9 +1,10 @@ import logging import re -from typing import List +from typing import List, Optional -from aiogram import Dispatcher, types, F +from aiogram import Bot, Dispatcher, types, F from aiogram.fsm.context import FSMContext +from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -24,6 +25,7 @@ from app.keyboards.admin import ( get_admin_campaigns_keyboard, get_admin_pagination_keyboard, get_campaign_bonus_type_keyboard, + get_campaign_edit_keyboard, get_campaign_management_keyboard, get_confirmation_keyboard, ) @@ -78,7 +80,12 @@ async def _get_bot_deep_link_from_message( def _build_campaign_servers_keyboard( - servers, selected_uuids: List[str] + servers, + selected_uuids: List[str], + *, + toggle_prefix: str = "campaign_toggle_server_", + save_callback: str = "campaign_servers_save", + back_callback: str = "admin_campaigns", ) -> types.InlineKeyboardMarkup: keyboard: List[List[types.InlineKeyboardButton]] = [] @@ -89,7 +96,7 @@ def _build_campaign_servers_keyboard( keyboard.append( [ types.InlineKeyboardButton( - text=text, callback_data=f"campaign_toggle_server_{server.id}" + text=text, callback_data=f"{toggle_prefix}{server.id}" ) ] ) @@ -97,15 +104,81 @@ def _build_campaign_servers_keyboard( keyboard.append( [ types.InlineKeyboardButton( - text="✅ Сохранить", callback_data="campaign_servers_save" + text="✅ Сохранить", callback_data=save_callback + ), + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data=back_callback ), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"), ] ) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) +async def _render_campaign_edit_menu( + bot: Bot, + chat_id: int, + message_id: int, + campaign, + language: str, + *, + original_message: Optional[types.Message] = None, +) -> int: + texts = get_texts(language) + text = ( + "✏️ Редактирование кампании\n\n" + f"{_format_campaign_summary(campaign, texts)}\n" + "Выберите, что изменить:" + ) + reply_markup = get_campaign_edit_keyboard( + campaign.id, + is_balance_bonus=campaign.is_balance_bonus, + language=language, + ) + + try: + await bot.edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + reply_markup=reply_markup, + parse_mode="HTML", + ) + return message_id + except TelegramBadRequest as exc: + if original_message and original_message.caption: + try: + await bot.edit_message_caption( + chat_id=chat_id, + message_id=message_id, + caption=text, + reply_markup=reply_markup, + parse_mode="HTML", + ) + return message_id + except TelegramBadRequest: + pass + + logger.debug( + "Falling back to sending new campaign edit menu message: %s", exc + ) + + new_message = await bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode="HTML", + ) + + if original_message: + try: + await original_message.delete() + except TelegramBadRequest: + logger.debug("Failed to delete original message during fallback") + + return new_message.message_id + + @admin_required @error_handler async def show_campaigns_menu( @@ -300,6 +373,763 @@ async def show_campaign_detail( await callback.answer() +@admin_required +@error_handler +async def show_campaign_edit_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + original_message=callback.message, + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_campaign_name( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_name) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "✏️ Изменение названия кампании\n\n" + f"Текущее название: {campaign.name}\n" + "Введите новое название (3-100 символов):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_name = message.text.strip() + if len(new_name) < 3 or len(new_name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + await update_campaign(db, campaign, name=new_name) + await state.clear() + + await message.answer("✅ Название обновлено.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_start_parameter( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_start) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "🔗 Изменение стартового параметра\n\n" + f"Текущий параметр: {campaign.start_parameter}\n" + "Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + new_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(new_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + existing = await get_campaign_by_start_parameter(db, new_param) + if existing and existing.id != campaign_id: + await message.answer("❌ Такой параметр уже используется. Введите другой вариант.") + return + + await update_campaign(db, campaign, start_parameter=new_param) + await state.clear() + + await message.answer("✅ Стартовый параметр обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_balance_bonus( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not campaign.is_balance_bonus: + await callback.answer("❌ У кампании другой тип бонуса", show_alert=True) + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_balance) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "💰 Изменение бонуса на баланс\n\n" + f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n" + "Введите новую сумму в рублях (например, 100 или 99.5):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_balance_bonus( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not campaign.is_balance_bonus: + await message.answer("❌ У кампании другой тип бонуса") + await state.clear() + return + + await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks) + await state.clear() + + await message.answer("✅ Бонус обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool: + if campaign.is_balance_bonus: + if isinstance(message_or_callback, types.CallbackQuery): + await message_or_callback.answer( + "❌ Для этой кампании доступен только бонус на баланс", + show_alert=True, + ) + else: + await message_or_callback.answer( + "❌ Для этой кампании нельзя изменить параметры подписки" + ) + return False + return True + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_days( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_days) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + await callback.message.edit_text( + ( + "📅 Изменение длительности подписки\n\n" + f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n" + "Введите новое количество дней (1-730):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_duration_days=days) + await state.clear() + + await message.answer("✅ Длительность подписки обновлена.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_traffic( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_traffic) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + current_traffic = campaign.subscription_traffic_gb or 0 + traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ" + + await callback.message.edit_text( + ( + "🌐 Изменение лимита трафика\n\n" + f"Текущее значение: {traffic_text}\n" + "Введите новый лимит в ГБ (0 = безлимит, максимум 10000):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_traffic_gb=traffic) + await state.clear() + + await message.answer("✅ Лимит трафика обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_devices( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_devices) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + ) + + current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + + await callback.message.edit_text( + ( + "📱 Изменение лимита устройств\n\n" + f"Текущее значение: {current_devices}\n" + f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):" + ), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await state.clear() + return + + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await message.answer("❌ Кампания не найдена") + await state.clear() + return + + if not await _ensure_subscription_campaign(message, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_device_limit=devices) + await state.clear() + + await message.answer("✅ Лимит устройств обновлен.") + + edit_message_id = data.get("campaign_edit_message_id") + if edit_message_id: + await _render_campaign_edit_menu( + message.bot, + message.chat.id, + edit_message_id, + campaign, + db_user.language, + ) + + +@admin_required +@error_handler +async def start_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + return + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await callback.answer( + "❌ Не найдены доступные серверы. Добавьте серверы перед изменением.", + show_alert=True, + ) + return + + selected = list(campaign.subscription_squads or []) + + await state.clear() + await state.set_state(AdminStates.editing_campaign_subscription_servers) + await state.update_data( + editing_campaign_id=campaign_id, + campaign_edit_message_id=callback.message.message_id, + campaign_subscription_squads=selected, + ) + + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_text( + ( + "🌍 Редактирование доступных серверов\n\n" + "Нажмите на сервер, чтобы добавить или убрать его из кампании.\n" + "После выбора нажмите \"✅ Сохранить\"." + ), + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_edit_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + parts = callback.data.split("_") + try: + server_id = int(parts[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Не удалось определить сервер", show_alert=True) + return + + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard( + servers, + selected, + toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", + save_callback=f"campaign_edit_servers_save_{campaign_id}", + back_callback=f"admin_campaign_edit_{campaign_id}", + ) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def save_edit_campaign_subscription_servers( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + campaign_id = data.get("editing_campaign_id") + if not campaign_id: + await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await state.clear() + return + + selected = list(data.get("campaign_subscription_squads", [])) + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await state.clear() + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + if not await _ensure_subscription_campaign(callback, campaign): + await state.clear() + return + + await update_campaign(db, campaign, subscription_squads=selected) + await state.clear() + + await _render_campaign_edit_menu( + callback.bot, + callback.message.chat.id, + callback.message.message_id, + campaign, + db_user.language, + original_message=callback.message, + ) + await callback.answer("✅ Сохранено") + + @admin_required @error_handler async def toggle_campaign_status( @@ -386,8 +1216,8 @@ async def confirm_delete_campaign( await callback.message.edit_text( text, reply_markup=get_confirmation_keyboard( - confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}", - cancel_callback=f"admin_campaign_manage_{campaign_id}", + confirm_action=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_action=f"admin_campaign_manage_{campaign_id}", ), ) await callback.answer() @@ -765,6 +1595,43 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( show_campaign_detail, F.data.startswith("admin_campaign_manage_") ) + dp.callback_query.register( + start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_") + ) + dp.callback_query.register( + start_edit_campaign_start_parameter, + F.data.startswith("admin_campaign_edit_start_"), + ) + dp.callback_query.register( + start_edit_campaign_balance_bonus, + F.data.startswith("admin_campaign_edit_balance_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_days, + F.data.startswith("admin_campaign_edit_sub_days_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_traffic, + F.data.startswith("admin_campaign_edit_sub_traffic_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_devices, + F.data.startswith("admin_campaign_edit_sub_devices_"), + ) + dp.callback_query.register( + start_edit_campaign_subscription_servers, + F.data.startswith("admin_campaign_edit_sub_servers_"), + ) + dp.callback_query.register( + save_edit_campaign_subscription_servers, + F.data.startswith("campaign_edit_servers_save_"), + ) + dp.callback_query.register( + toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_") + ) + dp.callback_query.register( + show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_") + ) dp.callback_query.register( delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") ) @@ -803,3 +1670,26 @@ def register_handlers(dp: Dispatcher): process_campaign_subscription_devices, AdminStates.creating_campaign_subscription_devices, ) + dp.message.register( + process_edit_campaign_name, AdminStates.editing_campaign_name + ) + dp.message.register( + process_edit_campaign_start_parameter, + AdminStates.editing_campaign_start, + ) + dp.message.register( + process_edit_campaign_balance_bonus, + AdminStates.editing_campaign_balance, + ) + dp.message.register( + process_edit_campaign_subscription_days, + AdminStates.editing_campaign_subscription_days, + ) + dp.message.register( + process_edit_campaign_subscription_traffic, + AdminStates.editing_campaign_subscription_traffic, + ) + dp.message.register( + process_edit_campaign_subscription_devices, + AdminStates.editing_campaign_subscription_devices, + ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index d49daf82..3ecee4a5 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -167,21 +167,109 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup: +def get_campaign_management_keyboard( + campaign_id: int, is_active: bool, language: str = "ru" +) -> InlineKeyboardMarkup: status_text = "🔴 Выключить" if is_active else "🟢 Включить" - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"), - InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}") - ], - [ - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}") - ], - [ - InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📊 Статистика", + callback_data=f"admin_campaign_stats_{campaign_id}", + ), + InlineKeyboardButton( + text=status_text, + callback_data=f"admin_campaign_toggle_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="✏️ Редактировать", + callback_data=f"admin_campaign_edit_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="🗑️ Удалить", + callback_data=f"admin_campaign_delete_{campaign_id}", + ) + ], + [ + InlineKeyboardButton( + text="⬅️ К списку", callback_data="admin_campaigns_list" + ) + ], ] - ]) + ) + + +def get_campaign_edit_keyboard( + campaign_id: int, + *, + is_balance_bonus: bool, + language: str = "ru", +) -> InlineKeyboardMarkup: + texts = get_texts(language) + + keyboard: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text="✏️ Название", + callback_data=f"admin_campaign_edit_name_{campaign_id}", + ), + InlineKeyboardButton( + text="🔗 Параметр", + callback_data=f"admin_campaign_edit_start_{campaign_id}", + ), + ] + ] + + if is_balance_bonus: + keyboard.append( + [ + InlineKeyboardButton( + text="💰 Бонус на баланс", + callback_data=f"admin_campaign_edit_balance_{campaign_id}", + ) + ] + ) + else: + keyboard.extend( + [ + [ + InlineKeyboardButton( + text="📅 Длительность", + callback_data=f"admin_campaign_edit_sub_days_{campaign_id}", + ), + InlineKeyboardButton( + text="🌐 Трафик", + callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}", + ), + ], + [ + InlineKeyboardButton( + text="📱 Устройства", + callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}", + ), + InlineKeyboardButton( + text="🌍 Серверы", + callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}", + ), + ], + ] + ) + + keyboard.append( + [ + InlineKeyboardButton( + text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}" + ) + ] + ) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: diff --git a/app/states.py b/app/states.py index 35f461e0..ac1f76f7 100644 --- a/app/states.py +++ b/app/states.py @@ -50,6 +50,14 @@ class AdminStates(StatesGroup): creating_campaign_subscription_traffic = State() creating_campaign_subscription_devices = State() creating_campaign_subscription_servers = State() + + editing_campaign_name = State() + editing_campaign_start = State() + editing_campaign_balance = State() + editing_campaign_subscription_days = State() + editing_campaign_subscription_traffic = State() + editing_campaign_subscription_devices = State() + editing_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() From 61758b8c2a73af7be3bf8860376d2b9591608526 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 13:26:54 +0300 Subject: [PATCH 31/33] Revert "Handle campaign edit menu for media messages" --- app/handlers/admin/campaigns.py | 55 +-------------------------------- 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 5587ba47..482221fa 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -1,10 +1,9 @@ import logging import re -from typing import List, Optional +from typing import List from aiogram import Bot, Dispatcher, types, F from aiogram.fsm.context import FSMContext -from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -121,9 +120,6 @@ async def _render_campaign_edit_menu( message_id: int, campaign, language: str, - *, - original_message: Optional[types.Message] = None, -) -> int: ): texts = get_texts(language) text = ( @@ -131,53 +127,6 @@ async def _render_campaign_edit_menu( f"{_format_campaign_summary(campaign, texts)}\n" "Выберите, что изменить:" ) - reply_markup = get_campaign_edit_keyboard( - campaign.id, - is_balance_bonus=campaign.is_balance_bonus, - language=language, - ) - - try: - await bot.edit_message_text( - text=text, - chat_id=chat_id, - message_id=message_id, - reply_markup=reply_markup, - parse_mode="HTML", - ) - return message_id - except TelegramBadRequest as exc: - if original_message and original_message.caption: - try: - await bot.edit_message_caption( - chat_id=chat_id, - message_id=message_id, - caption=text, - reply_markup=reply_markup, - parse_mode="HTML", - ) - return message_id - except TelegramBadRequest: - pass - - logger.debug( - "Falling back to sending new campaign edit menu message: %s", exc - ) - - new_message = await bot.send_message( - chat_id=chat_id, - text=text, - reply_markup=reply_markup, - parse_mode="HTML", - ) - - if original_message: - try: - await original_message.delete() - except TelegramBadRequest: - logger.debug("Failed to delete original message during fallback") - - return new_message.message_id await bot.edit_message_text( text=text, @@ -410,7 +359,6 @@ async def show_campaign_edit_menu( callback.message.message_id, campaign, db_user.language, - original_message=callback.message, ) await callback.answer() @@ -1138,7 +1086,6 @@ async def save_edit_campaign_subscription_servers( callback.message.message_id, campaign, db_user.language, - original_message=callback.message, ) await callback.answer("✅ Сохранено") From 98ec7e4165a708efe8425d5ba6e0fceafcb8006b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 13:36:27 +0300 Subject: [PATCH 32/33] Fix campaign edit menu for captioned messages --- app/handlers/admin/campaigns.py | 48 +++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 482221fa..22ff8aac 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -120,6 +120,8 @@ async def _render_campaign_edit_menu( message_id: int, campaign, language: str, + *, + use_caption: bool = False, ): texts = get_texts(language) text = ( @@ -128,8 +130,7 @@ async def _render_campaign_edit_menu( "Выберите, что изменить:" ) - await bot.edit_message_text( - text=text, + edit_kwargs = dict( chat_id=chat_id, message_id=message_id, reply_markup=get_campaign_edit_keyboard( @@ -140,6 +141,17 @@ async def _render_campaign_edit_menu( parse_mode="HTML", ) + if use_caption: + await bot.edit_message_caption( + caption=text, + **edit_kwargs, + ) + else: + await bot.edit_message_text( + text=text, + **edit_kwargs, + ) + @admin_required @error_handler @@ -353,12 +365,15 @@ async def show_campaign_edit_menu( await state.clear() + use_caption = bool(callback.message.caption) and not bool(callback.message.text) + await _render_campaign_edit_menu( callback.bot, callback.message.chat.id, callback.message.message_id, campaign, db_user.language, + use_caption=use_caption, ) await callback.answer() @@ -379,9 +394,11 @@ async def start_edit_campaign_name( await state.clear() await state.set_state(AdminStates.editing_campaign_name) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( @@ -438,6 +455,7 @@ async def process_edit_campaign_name( await message.answer("✅ Название обновлено.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -445,6 +463,7 @@ async def process_edit_campaign_name( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -464,9 +483,11 @@ async def start_edit_campaign_start_parameter( await state.clear() await state.set_state(AdminStates.editing_campaign_start) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( @@ -528,6 +549,7 @@ async def process_edit_campaign_start_parameter( await message.answer("✅ Стартовый параметр обновлен.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -535,6 +557,7 @@ async def process_edit_campaign_start_parameter( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -558,9 +581,11 @@ async def start_edit_campaign_balance_bonus( await state.clear() await state.set_state(AdminStates.editing_campaign_balance) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( @@ -627,6 +652,7 @@ async def process_edit_campaign_balance_bonus( await message.answer("✅ Бонус обновлен.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -634,6 +660,7 @@ async def process_edit_campaign_balance_bonus( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -671,9 +698,11 @@ async def start_edit_campaign_subscription_days( await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_days) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( @@ -737,6 +766,7 @@ async def process_edit_campaign_subscription_days( await message.answer("✅ Длительность подписки обновлена.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -744,6 +774,7 @@ async def process_edit_campaign_subscription_days( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -766,9 +797,11 @@ async def start_edit_campaign_subscription_traffic( await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_traffic) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) current_traffic = campaign.subscription_traffic_gb or 0 @@ -835,6 +868,7 @@ async def process_edit_campaign_subscription_traffic( await message.answer("✅ Лимит трафика обновлен.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -842,6 +876,7 @@ async def process_edit_campaign_subscription_traffic( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -864,9 +899,11 @@ async def start_edit_campaign_subscription_devices( await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_devices) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, + campaign_edit_message_is_caption=is_caption, ) current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT @@ -934,6 +971,7 @@ async def process_edit_campaign_subscription_devices( await message.answer("✅ Лимит устройств обновлен.") edit_message_id = data.get("campaign_edit_message_id") + edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -941,6 +979,7 @@ async def process_edit_campaign_subscription_devices( edit_message_id, campaign, db_user.language, + use_caption=edit_message_is_caption, ) @@ -973,10 +1012,12 @@ async def start_edit_campaign_subscription_servers( await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_servers) + is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_subscription_squads=selected, + campaign_edit_message_is_caption=is_caption, ) keyboard = _build_campaign_servers_keyboard( @@ -1080,12 +1121,15 @@ async def save_edit_campaign_subscription_servers( await update_campaign(db, campaign, subscription_squads=selected) await state.clear() + use_caption = bool(callback.message.caption) and not bool(callback.message.text) + await _render_campaign_edit_menu( callback.bot, callback.message.chat.id, callback.message.message_id, campaign, db_user.language, + use_caption=use_caption, ) await callback.answer("✅ Сохранено") From ec1b45968937c203c6fb5cb40343115e2c1f65c5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 15:36:11 +0300 Subject: [PATCH 33/33] Unify admin subscription and settings menu --- app/handlers/admin/users.py | 339 ++++++++++++++++++------------------ app/keyboards/admin.py | 3 +- 2 files changed, 167 insertions(+), 175 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 02596078..a88a0650 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -412,6 +412,148 @@ async def show_users_statistics( await callback.answer() +async def _render_user_subscription_overview( + callback: types.CallbackQuery, + db: AsyncSession, + user_id: int +) -> bool: + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return False + + user = profile["user"] + subscription = profile["subscription"] + + text = "📱 Подписка и настройки пользователя\n\n" + text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" + + keyboard = [] + + if subscription: + status_emoji = "✅" if subscription.is_active else "❌" + type_emoji = "🎁" if subscription.is_trial else "💎" + + traffic_display = f"{subscription.traffic_used_gb:.1f}/" + if subscription.traffic_limit_gb == 0: + traffic_display += "♾️ ГБ" + else: + traffic_display += f"{subscription.traffic_limit_gb} ГБ" + + text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" + text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" + text += f"Начало: {format_datetime(subscription.start_date)}\n" + text += f"Окончание: {format_datetime(subscription.end_date)}\n" + text += f"Трафик: {traffic_display}\n" + text += f"Устройства: {subscription.device_limit}\n" + + if subscription.is_active: + days_left = (subscription.end_date - datetime.utcnow()).days + text += f"Осталось дней: {days_left}\n" + + current_squads = subscription.connected_squads or [] + if current_squads: + text += "\nПодключенные серверы:\n" + for squad_uuid in current_squads: + try: + server = await get_server_squad_by_uuid(db, squad_uuid) + if server: + text += f"• {server.display_name}\n" + else: + text += f"• {squad_uuid[:8]}... (неизвестный)\n" + except Exception as e: + logger.error(f"Ошибка получения сервера {squad_uuid}: {e}") + text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n" + else: + text += "\nПодключенные серверы: отсутствуют\n" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="⏰ Продлить", + callback_data=f"admin_sub_extend_{user_id}" + ), + types.InlineKeyboardButton( + text="💳 Купить подписку", + callback_data=f"admin_sub_buy_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🔄 Тип подписки", + callback_data=f"admin_sub_change_type_{user_id}" + ), + types.InlineKeyboardButton( + text="📊 Добавить трафик", + callback_data=f"admin_sub_traffic_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🌍 Сменить сервер", + callback_data=f"admin_user_change_server_{user_id}" + ), + types.InlineKeyboardButton( + text="📱 Устройства", + callback_data=f"admin_user_devices_{user_id}" + ) + ], + [ + types.InlineKeyboardButton( + text="🛠️ Лимит трафика", + callback_data=f"admin_user_traffic_{user_id}" + ), + types.InlineKeyboardButton( + text="🔄 Сбросить устройства", + callback_data=f"admin_user_reset_devices_{user_id}" + ) + ] + ] + + if subscription.is_active: + keyboard.append([ + types.InlineKeyboardButton( + text="🚫 Деактивировать", + callback_data=f"admin_sub_deactivate_{user_id}" + ) + ]) + else: + keyboard.append([ + types.InlineKeyboardButton( + text="✅ Активировать", + callback_data=f"admin_sub_activate_{user_id}" + ) + ]) + else: + text += "❌ Подписка отсутствует\n\n" + text += "Пользователь еще не активировал подписку." + + keyboard = [ + [ + types.InlineKeyboardButton( + text="🎁 Выдать триал", + callback_data=f"admin_sub_grant_trial_{user_id}" + ), + types.InlineKeyboardButton( + text="💎 Выдать подписку", + callback_data=f"admin_sub_grant_{user_id}" + ) + ] + ] + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + return True + + @admin_required @error_handler async def show_user_subscription( @@ -419,101 +561,11 @@ async def show_user_subscription( db_user: User, db: AsyncSession ): - + user_id = int(callback.data.split('_')[-1]) - - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - user = profile["user"] - subscription = profile["subscription"] - - text = f"📱 Подписка пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" - - if subscription: - status_emoji = "✅" if subscription.is_active else "❌" - type_emoji = "🎁" if subscription.is_trial else "💎" - - text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" - text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" - text += f"Начало: {format_datetime(subscription.start_date)}\n" - text += f"Окончание: {format_datetime(subscription.end_date)}\n" - text += f"Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n" - text += f"Устройства: {subscription.device_limit}\n" - text += f"Подключенных устройств: {subscription.device_limit}\n" - - if subscription.is_active: - days_left = (subscription.end_date - datetime.utcnow()).days - text += f"Осталось дней: {days_left}\n" - - keyboard = [ - [ - types.InlineKeyboardButton( - text="⏰ Продлить", - callback_data=f"admin_sub_extend_{user_id}" - ), - types.InlineKeyboardButton( - text="📊 Трафик", - callback_data=f"admin_sub_traffic_{user_id}" - ) - ], - [ - types.InlineKeyboardButton( - text="🔄 Тип подписки", - callback_data=f"admin_sub_change_type_{user_id}" - ), - types.InlineKeyboardButton( - text="💳 Купить подписку", - callback_data=f"admin_sub_buy_{user_id}" - ) - ] - ] - - if subscription.is_active: - keyboard.append([ - types.InlineKeyboardButton( - text="🚫 Деактивировать", - callback_data=f"admin_sub_deactivate_{user_id}" - ) - ]) - else: - keyboard.append([ - types.InlineKeyboardButton( - text="✅ Активировать", - callback_data=f"admin_sub_activate_{user_id}" - ) - ]) - else: - text += "❌ Подписка отсутствует\n\n" - text += "Пользователь еще не активировал подписку." - - keyboard = [ - [ - types.InlineKeyboardButton( - text="🎁 Выдать триал", - callback_data=f"admin_sub_grant_trial_{user_id}" - ), - types.InlineKeyboardButton( - text="💎 Выдать подписку", - callback_data=f"admin_sub_grant_{user_id}" - ) - ] - ] - - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + if await _render_user_subscription_overview(callback, db, user_id): + await callback.answer() @admin_required @@ -1609,68 +1661,9 @@ async def show_user_servers_management( db: AsyncSession ): user_id = int(callback.data.split('_')[-1]) - - user_service = UserService() - profile = await user_service.get_user_profile(db, user_id) - - if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) - return - - user = profile["user"] - subscription = profile["subscription"] - - text = f"🌍 Управление серверами пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" - - if subscription: - current_squads = subscription.connected_squads or [] - - if current_squads: - text += f"Текущие серверы ({len(current_squads)}):\n" - - for squad_uuid in current_squads: - try: - server = await get_server_squad_by_uuid(db, squad_uuid) - if server: - text += f"• {server.display_name}\n" - else: - text += f"• {squad_uuid[:8]}... (неизвестный)\n" - except Exception as e: - logger.error(f"Ошибка получения сервера {squad_uuid}: {e}") - text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n" - else: - text += "Серверы: Не подключены\n" - - text += f"\nУстройства: {subscription.device_limit}\n" - traffic_display = f"{subscription.traffic_used_gb:.1f}/" - if subscription.traffic_limit_gb == 0: - traffic_display += "∞ ГБ" - else: - traffic_display += f"{subscription.traffic_limit_gb} ГБ" - text += f"Трафик: {traffic_display}\n" - else: - text += "❌ Подписка отсутствует" - - keyboard = [ - [ - types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"), - types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}") - ], - [ - types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"), - types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}") - ], - [ - types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") - ] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + if await _render_user_subscription_overview(callback, db, user_id): + await callback.answer() @admin_required @@ -1706,7 +1699,7 @@ async def _show_servers_for_user( await callback.message.edit_text( "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] ]) ) return @@ -1749,8 +1742,8 @@ async def _show_servers_for_user( text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов" keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") ]) await callback.message.edit_text( @@ -1841,7 +1834,7 @@ async def refresh_server_selection_screen( await callback.message.edit_text( "❌ Доступные серверы не найдены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] ]) ) return @@ -1865,8 +1858,8 @@ async def refresh_server_selection_screen( text += f"\n📝 Показано первых 15 из {len(servers)} серверов" keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") ]) await callback.message.edit_text( @@ -1906,7 +1899,7 @@ async def start_devices_edit( types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10") ], [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") ] ]) ) @@ -1932,14 +1925,14 @@ async def set_user_devices_button( await callback.message.edit_text( f"✅ Количество устройств изменено на: {devices}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: await callback.message.edit_text( "❌ Ошибка изменения количества устройств", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) @@ -1975,7 +1968,7 @@ async def process_devices_edit_text( await message.answer( f"✅ Количество устройств изменено на: {devices}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: @@ -2019,7 +2012,7 @@ async def start_traffic_edit( types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0") ], [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}") + types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") ] ]) ) @@ -2046,14 +2039,14 @@ async def set_user_traffic_button( await callback.message.edit_text( f"✅ Лимит трафика изменен на: {traffic_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: await callback.message.edit_text( "❌ Ошибка изменения лимита трафика", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) @@ -2090,7 +2083,7 @@ async def process_traffic_edit_text( await message.answer( f"✅ Лимит трафика изменен на: {traffic_text}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) else: @@ -2122,7 +2115,7 @@ async def confirm_reset_devices( "Продолжить?", reply_markup=get_confirmation_keyboard( f"admin_user_reset_devices_confirm_{user_id}", - f"admin_user_servers_{user_id}", + f"admin_user_subscription_{user_id}", db_user.language ) ) @@ -2152,7 +2145,7 @@ async def reset_user_devices( await callback.message.edit_text( "✅ Устройства пользователя успешно сброшены", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}") @@ -2160,7 +2153,7 @@ async def reset_user_devices( await callback.message.edit_text( "❌ Ошибка сброса устройств", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")] + [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] ]) ) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c83fdd7a..d0ecf018 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -393,10 +393,9 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = keyboard = [ [ InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"), - InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}") + InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}") ], [ - InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"), InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}") ], [