From 400cc5a32e0d63746fc582271022264a305456dd Mon Sep 17 00:00:00 2001 From: gy9vin Date: Tue, 30 Sep 2025 11:55:04 +0300 Subject: [PATCH 01/24] =?UTF-8?q?=D0=9E=D0=B6=D0=B8=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=20"=D0=94=D0=B5=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/handlers/admin/users.py | 53 +++++++++++-------------------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 29d2e85c..64d20f98 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -23,6 +23,7 @@ from app.localization.texts import get_texts from app.services.user_service import UserService from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts +from app.database.crud.subscription import get_subscriptions_statistics 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 @@ -355,54 +356,30 @@ async def show_users_statistics( user_service = UserService() stats = await user_service.get_user_statistics(db) - - from sqlalchemy import select, func, or_ - current_time = datetime.utcnow() + from sqlalchemy import select, func - active_subscription_query = ( - select(func.count(Subscription.id)) - .join(User, Subscription.user_id == User.id) - .where( - User.status == UserStatus.ACTIVE.value, - Subscription.status.in_( - [ - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.TRIAL.value, - ] - ), - Subscription.end_date > current_time, - ) - ) - users_with_subscription = ( - await db.execute(active_subscription_query) - ).scalar() or 0 + sub_stats = await get_subscriptions_statistics(db) - trial_subscription_query = ( - select(func.count(Subscription.id)) - .join(User, Subscription.user_id == User.id) - .where( - User.status == UserStatus.ACTIVE.value, - Subscription.end_date > current_time, - or_( - Subscription.status == SubscriptionStatus.TRIAL.value, - Subscription.is_trial.is_(True), - ), - ) - ) - trial_users = (await db.execute(trial_subscription_query)).scalar() or 0 + users_with_subscription = int(sub_stats.get("active_subscriptions", 0) or 0) + trial_users = int(sub_stats.get("trial_subscriptions", 0) or 0) + paid_users = max(users_with_subscription - trial_users, 0) users_without_subscription = max( stats["active_users"] - users_with_subscription, 0, ) - + avg_balance_result = await db.execute( select(func.avg(User.balance_kopeks)) .where(User.status == UserStatus.ACTIVE.value) ) avg_balance = avg_balance_result.scalar() or 0 - + avg_balance_kopeks = int(round(avg_balance)) + + conversion_percent = (paid_users / max(stats["active_users"], 1) * 100) + trial_share_percent = (trial_users / max(users_with_subscription, 1) * 100) + text = f""" 📊 Детальная статистика пользователей @@ -417,7 +394,7 @@ async def show_users_statistics( • Без подписки: {users_without_subscription} 💰 Финансы: -• Средний баланс: {settings.format_price(int(avg_balance))} +• Средний баланс: {settings.format_price(avg_balance_kopeks)} 📈 Регистрации: • Сегодня: {stats['new_today']} @@ -425,8 +402,8 @@ async def show_users_statistics( • За месяц: {stats['new_month']} 📊 Активность: -• Конверсия в подписку: {(users_with_subscription / max(stats['active_users'], 1) * 100):.1f}% -• Доля триальных: {(trial_users / max(users_with_subscription, 1) * 100):.1f}% +• Конверсия в подписку: {conversion_percent:.1f}% +• Доля триальных: {trial_share_percent:.1f}% """ await callback.message.edit_text( From c344f418c54ff50afbfbb023f23a1f0a16ee3021 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Tue, 30 Sep 2025 12:39:15 +0300 Subject: [PATCH 02/24] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/database/crud/user.py | 135 +++++++- app/handlers/admin/users.py | 602 ++++++++++++++++++++++++++++++++++- app/keyboards/admin.py | 15 + app/services/user_service.py | 131 +++++++- app/states.py | 5 + 5 files changed, 871 insertions(+), 17 deletions(-) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index a7fd93d4..98ee1f75 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -2,8 +2,8 @@ import logging import secrets import string from datetime import datetime, timedelta -from typing import Optional, List -from sqlalchemy import select, and_, or_, func +from typing import Optional, List, Dict +from sqlalchemy import select, and_, or_, func, case, nullslast from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -273,7 +273,11 @@ async def get_users_list( limit: int = 50, search: Optional[str] = None, status: Optional[UserStatus] = None, - order_by_balance: bool = False + order_by_balance: bool = False, + order_by_traffic: bool = False, + order_by_last_activity: bool = False, + order_by_total_spent: bool = False, + order_by_purchase_count: bool = False ) -> List[User]: query = select(User).options(selectinload(User.subscription)) @@ -293,10 +297,71 @@ async def get_users_list( conditions.append(User.telegram_id == int(search)) query = query.where(or_(*conditions)) - - # Сортировка по балансу в порядке убывания, если order_by_balance=True - if order_by_balance: - query = query.order_by(User.balance_kopeks.desc()) + + sort_flags = [ + order_by_balance, + order_by_traffic, + order_by_last_activity, + order_by_total_spent, + order_by_purchase_count, + ] + if sum(int(flag) for flag in sort_flags) > 1: + logger.debug( + "Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность" + ) + + transactions_stats = None + if order_by_total_spent or order_by_purchase_count: + from app.database.models import Transaction + + transactions_stats = ( + select( + Transaction.user_id.label("user_id"), + func.coalesce( + func.sum( + case( + ( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.amount_kopeks, + ), + else_=0, + ) + ), + 0, + ).label("total_spent"), + func.coalesce( + func.sum( + case( + ( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + 1, + ), + else_=0, + ) + ), + 0, + ).label("purchase_count"), + ) + .where(Transaction.is_completed.is_(True)) + .group_by(Transaction.user_id) + .subquery() + ) + query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id) + + if order_by_traffic: + traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0) + query = query.outerjoin(Subscription, Subscription.user_id == User.id) + query = query.order_by(traffic_sort.desc(), User.created_at.desc()) + elif order_by_total_spent: + order_column = func.coalesce(transactions_stats.c.total_spent, 0) + query = query.order_by(order_column.desc(), User.created_at.desc()) + elif order_by_purchase_count: + order_column = func.coalesce(transactions_stats.c.purchase_count, 0) + query = query.order_by(order_column.desc(), User.created_at.desc()) + elif order_by_balance: + query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc()) + elif order_by_last_activity: + query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc()) else: query = query.order_by(User.created_at.desc()) @@ -334,6 +399,62 @@ async def get_users_count( return result.scalar() +async def get_users_spending_stats( + db: AsyncSession, + user_ids: List[int] +) -> Dict[int, Dict[str, int]]: + if not user_ids: + return {} + + from app.database.models import Transaction + + stats_query = ( + select( + Transaction.user_id, + func.coalesce( + func.sum( + case( + ( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.amount_kopeks, + ), + else_=0, + ) + ), + 0, + ).label("total_spent"), + func.coalesce( + func.sum( + case( + ( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + 1, + ), + else_=0, + ) + ), + 0, + ).label("purchase_count"), + ) + .where( + Transaction.user_id.in_(user_ids), + Transaction.is_completed.is_(True), + ) + .group_by(Transaction.user_id) + ) + + result = await db.execute(stats_query) + rows = result.all() + + return { + row.user_id: { + "total_spent": int(row.total_spent or 0), + "purchase_count": int(row.purchase_count or 0), + } + for row in rows + } + + async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: result = await db.execute( select(User) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 64d20f98..c786c1cc 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -81,7 +81,7 @@ async def show_users_filters( state: FSMContext ): - text = "⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:" + text = ("⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:\n") await callback.message.edit_text( text, @@ -289,6 +289,464 @@ async def show_users_list_by_balance( await callback.answer() +@admin_required +@error_handler +async def show_users_list_by_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + await state.set_state(AdminStates.viewing_user_from_traffic_list) + + user_service = UserService() + users_data = await user_service.get_users_page( + db, page=page, limit=10, order_by_traffic=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 = "🗑️" + + if user.subscription: + sub = user.subscription + if sub.is_trial: + subscription_emoji = "🎁" + elif sub.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + used = sub.traffic_used_gb or 0.0 + if sub.traffic_limit_gb and sub.traffic_limit_gb > 0: + limit_display = f"{sub.traffic_limit_gb}" + else: + limit_display = "♾️" + traffic_display = f"{used:.1f}/{limit_display} ГБ" + else: + subscription_emoji = "❌" + traffic_display = "нет подписки" + + button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" + button_text += f" | 📶 {traffic_display}" + + if user.balance_kopeks > 0: + button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + + 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}" + button_text += f" | 📶 {traffic_display}" + + 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_traffic_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 show_users_list_by_last_activity( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + await state.set_state(AdminStates.viewing_user_from_last_activity_list) + + user_service = UserService() + users_data = await user_service.get_users_page( + db, + page=page, + limit=10, + order_by_last_activity=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 = "🗑️" + + activity_display = ( + format_time_ago(user.last_activity) + if user.last_activity + else "неизвестно" + ) + + subscription_emoji = "❌" + if user.subscription: + if user.subscription.is_trial: + subscription_emoji = "🎁" + elif user.subscription.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + + button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" + button_text += f" | 🕒 {activity_display}" + + 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_activity_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 show_users_list_by_spending( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + await state.set_state(AdminStates.viewing_user_from_spending_list) + + user_service = UserService() + users_data = await user_service.get_users_page( + db, + page=page, + limit=10, + order_by_total_spent=True, + ) + + users = users_data["users"] + if not users: + await callback.message.edit_text( + "💳 Пользователи с тратами не найдены", + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + return + + spending_map = await user_service.get_user_spending_stats_map( + db, + [user.id for user in users], + ) + + text = f"👥 Пользователи по сумме трат (стр. {page}/{users_data['total_pages']})\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + + for user in users: + stats = spending_map.get( + user.id, + {"total_spent": 0, "purchase_count": 0}, + ) + total_spent = stats.get("total_spent", 0) + purchases = stats.get("purchase_count", 0) + + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️" + + button_text = ( + f"{status_emoji} {user.full_name}" + f" | 💳 {settings.format_price(total_spent)}" + f" | 🛒 {purchases}" + ) + + 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_spending_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 show_users_list_by_purchases( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + await state.set_state(AdminStates.viewing_user_from_purchases_list) + + user_service = UserService() + users_data = await user_service.get_users_page( + db, + page=page, + limit=10, + order_by_purchase_count=True, + ) + + users = users_data["users"] + if not users: + await callback.message.edit_text( + "🛒 Пользователи с покупками не найдены", + reply_markup=get_admin_users_keyboard(db_user.language) + ) + await callback.answer() + return + + spending_map = await user_service.get_user_spending_stats_map( + db, + [user.id for user in users], + ) + + text = f"👥 Пользователи по количеству покупок (стр. {page}/{users_data['total_pages']})\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + + for user in users: + stats = spending_map.get( + user.id, + {"total_spent": 0, "purchase_count": 0}, + ) + total_spent = stats.get("total_spent", 0) + purchases = stats.get("purchase_count", 0) + + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️" + + button_text = ( + f"{status_emoji} {user.full_name}" + f" | 🛒 {purchases}" + f" | 💳 {settings.format_price(total_spent)}" + ) + + 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_purchases_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 show_users_list_by_campaign( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + + await state.set_state(AdminStates.viewing_user_from_campaign_list) + + user_service = UserService() + users_data = await user_service.get_users_by_campaign_page( + db, + page=page, + limit=10, + ) + + users = users_data.get("users", []) + campaign_map = users_data.get("campaigns", {}) + + if not 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: + info = campaign_map.get(user.id, {}) + campaign_name = info.get("campaign_name") or "Без кампании" + registered_at = info.get("registered_at") + registered_display = format_datetime(registered_at) if registered_at else "неизвестно" + + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️" + + button_text = ( + f"{status_emoji} {user.full_name}" + f" | 📢 {campaign_name}" + f" | 📅 {registered_display}" + ) + + 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_campaign_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( @@ -323,6 +781,91 @@ async def handle_users_balance_list_pagination( await show_users_list_by_balance(callback, db_user, db, state, 1) +@admin_required +@error_handler +async def handle_users_traffic_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_traffic(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_traffic(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_activity_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_last_activity(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_last_activity(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_spending_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_spending(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_spending(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_purchases_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_purchases(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_purchases(callback, db_user, db, state, 1) + + +@admin_required +@error_handler +async def handle_users_campaign_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_campaign(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_list_by_campaign(callback, db_user, db, state, 1) + + @admin_required @error_handler async def start_user_search( @@ -867,6 +1410,16 @@ async def show_user_management( current_state = await state.get_state() if current_state == AdminStates.viewing_user_from_balance_list: back_callback = "admin_users_balance_filter" + elif current_state == AdminStates.viewing_user_from_traffic_list: + back_callback = "admin_users_traffic_filter" + elif current_state == AdminStates.viewing_user_from_last_activity_list: + back_callback = "admin_users_activity_filter" + elif current_state == AdminStates.viewing_user_from_spending_list: + back_callback = "admin_users_spending_filter" + elif current_state == AdminStates.viewing_user_from_purchases_list: + back_callback = "admin_users_purchases_filter" + elif current_state == AdminStates.viewing_user_from_campaign_list: + back_callback = "admin_users_campaign_filter" # Базовая клавиатура профиля kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) @@ -3317,6 +3870,31 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_users_balance_list_page_") ) + dp.callback_query.register( + handle_users_traffic_list_pagination, + F.data.startswith("admin_users_traffic_list_page_") + ) + + dp.callback_query.register( + handle_users_activity_list_pagination, + F.data.startswith("admin_users_activity_list_page_") + ) + + dp.callback_query.register( + handle_users_spending_list_pagination, + F.data.startswith("admin_users_spending_list_page_") + ) + + dp.callback_query.register( + handle_users_purchases_list_pagination, + F.data.startswith("admin_users_purchases_list_page_") + ) + + dp.callback_query.register( + handle_users_campaign_list_pagination, + F.data.startswith("admin_users_campaign_list_page_") + ) + dp.callback_query.register( start_user_search, F.data == "admin_users_search" @@ -3522,9 +4100,27 @@ def register_handlers(dp: Dispatcher): ) dp.callback_query.register( - show_users_list_by_balance, - F.data.startswith("admin_users_balance_list_page_") + show_users_list_by_traffic, + F.data == "admin_users_traffic_filter" ) + dp.callback_query.register( + show_users_list_by_last_activity, + F.data == "admin_users_activity_filter" + ) + dp.callback_query.register( + show_users_list_by_spending, + F.data == "admin_users_spending_filter" + ) + dp.callback_query.register( + show_users_list_by_purchases, + F.data == "admin_users_purchases_filter" + ) + + dp.callback_query.register( + show_users_list_by_campaign, + F.data == "admin_users_campaign_filter" + ) + diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 809481d9..42bb14d3 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -175,6 +175,21 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark [ InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter") ], + [ + InlineKeyboardButton(text="📶 По трафику", callback_data="admin_users_traffic_filter") + ], + [ + InlineKeyboardButton(text="🕒 По активности", callback_data="admin_users_activity_filter") + ], + [ + InlineKeyboardButton(text="💳 По сумме трат", callback_data="admin_users_spending_filter") + ], + [ + InlineKeyboardButton(text="🛒 По количеству покупок", callback_data="admin_users_purchases_filter") + ], + [ + InlineKeyboardButton(text="📢 По кампании", callback_data="admin_users_campaign_filter") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") ] diff --git a/app/services/user_service.py b/app/services/user_service.py index 3a20b857..89a6139a 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -2,13 +2,14 @@ import logging from datetime import datetime, timedelta from typing import Optional, List, Dict, Any, Tuple from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import delete, select, update +from sqlalchemy import delete, select, update, func from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from app.database.crud.user import ( get_user_by_id, get_user_by_telegram_id, get_users_list, get_users_count, get_users_statistics, get_inactive_users, - add_user_balance, subtract_user_balance, update_user, delete_user + add_user_balance, subtract_user_balance, update_user, delete_user, + get_users_spending_stats ) from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.transaction import get_user_transactions_count @@ -18,7 +19,8 @@ from app.database.models import ( ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText, SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, - AdvertisingCampaign, PaymentMethod + AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, + TransactionType ) from app.config import settings @@ -141,20 +143,32 @@ class UserService: "has_next": False, "has_prev": False } - + async def get_users_page( self, db: AsyncSession, page: int = 1, limit: int = 20, status: Optional[UserStatus] = None, - order_by_balance: bool = False + order_by_balance: bool = False, + order_by_traffic: bool = False, + order_by_last_activity: bool = False, + order_by_total_spent: bool = False, + order_by_purchase_count: bool = False ) -> Dict[str, Any]: try: offset = (page - 1) * limit users = await get_users_list( - db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance + db, + offset=offset, + limit=limit, + status=status, + order_by_balance=order_by_balance, + order_by_traffic=order_by_traffic, + order_by_last_activity=order_by_last_activity, + order_by_total_spent=order_by_total_spent, + order_by_purchase_count=order_by_purchase_count, ) total_count = await get_users_count(db, status=status) @@ -179,7 +193,110 @@ class UserService: "has_next": False, "has_prev": False } - + + async def get_user_spending_stats_map( + self, + db: AsyncSession, + user_ids: List[int] + ) -> Dict[int, Dict[str, int]]: + try: + return await get_users_spending_stats(db, user_ids) + except Exception as e: + logger.error(f"Ошибка получения статистики трат пользователей: {e}") + return {} + + async def get_users_by_campaign_page( + self, + db: AsyncSession, + page: int = 1, + limit: int = 20 + ) -> Dict[str, Any]: + try: + offset = (page - 1) * limit + + campaign_ranked = ( + select( + AdvertisingCampaignRegistration.user_id.label("user_id"), + AdvertisingCampaignRegistration.campaign_id.label("campaign_id"), + AdvertisingCampaignRegistration.created_at.label("created_at"), + func.row_number() + .over( + partition_by=AdvertisingCampaignRegistration.user_id, + order_by=AdvertisingCampaignRegistration.created_at.desc(), + ) + .label("rn"), + ) + .cte("campaign_ranked") + ) + + latest_campaign = ( + select( + campaign_ranked.c.user_id, + campaign_ranked.c.campaign_id, + campaign_ranked.c.created_at, + ) + .where(campaign_ranked.c.rn == 1) + .subquery() + ) + + query = ( + select( + User, + AdvertisingCampaign.name.label("campaign_name"), + latest_campaign.c.created_at, + ) + .join(latest_campaign, latest_campaign.c.user_id == User.id) + .join( + AdvertisingCampaign, + AdvertisingCampaign.id == latest_campaign.c.campaign_id, + ) + .order_by( + AdvertisingCampaign.name.asc(), + latest_campaign.c.created_at.desc(), + ) + .offset(offset) + .limit(limit) + ) + + result = await db.execute(query) + rows = result.all() + + users = [row[0] for row in rows] + campaign_map = { + row[0].id: { + "campaign_name": row[1], + "registered_at": row[2], + } + for row in rows + } + + total_stmt = select(func.count()).select_from(latest_campaign) + total_result = await db.execute(total_stmt) + total_count = total_result.scalar() or 0 + total_pages = (total_count + limit - 1) // limit if total_count else 1 + + return { + "users": users, + "campaigns": campaign_map, + "current_page": page, + "total_pages": total_pages, + "total_count": total_count, + "has_next": page < total_pages, + "has_prev": page > 1, + } + + except Exception as e: + logger.error(f"Ошибка получения пользователей по кампаниям: {e}") + return { + "users": [], + "campaigns": {}, + "current_page": 1, + "total_pages": 1, + "total_count": 0, + "has_next": False, + "has_prev": False, + } + async def update_user_balance( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 7dbc74e6..43655b35 100644 --- a/app/states.py +++ b/app/states.py @@ -108,6 +108,11 @@ class AdminStates(StatesGroup): # Состояния для отслеживания источника перехода viewing_user_from_balance_list = State() + viewing_user_from_traffic_list = State() + viewing_user_from_last_activity_list = State() + viewing_user_from_spending_list = State() + viewing_user_from_purchases_list = State() + viewing_user_from_campaign_list = State() class SupportStates(StatesGroup): waiting_for_message = State() From 0f621bfe5a2dbfb9a52f7e2bf5ff088939df88f1 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Tue, 30 Sep 2025 12:42:17 +0300 Subject: [PATCH 03/24] =?UTF-8?q?Revert=20"=D0=9E=D0=B6=D0=B8=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20"=D0=94=D0=B5=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 400cc5a32e0d63746fc582271022264a305456dd. --- app/handlers/admin/users.py | 53 ++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index c786c1cc..dcd29959 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -23,7 +23,6 @@ from app.localization.texts import get_texts from app.services.user_service import UserService from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts -from app.database.crud.subscription import get_subscriptions_statistics 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 @@ -899,30 +898,54 @@ async def show_users_statistics( user_service = UserService() stats = await user_service.get_user_statistics(db) + + from sqlalchemy import select, func, or_ - from sqlalchemy import select, func + current_time = datetime.utcnow() - sub_stats = await get_subscriptions_statistics(db) + active_subscription_query = ( + select(func.count(Subscription.id)) + .join(User, Subscription.user_id == User.id) + .where( + User.status == UserStatus.ACTIVE.value, + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + ] + ), + Subscription.end_date > current_time, + ) + ) + users_with_subscription = ( + await db.execute(active_subscription_query) + ).scalar() or 0 - users_with_subscription = int(sub_stats.get("active_subscriptions", 0) or 0) - trial_users = int(sub_stats.get("trial_subscriptions", 0) or 0) - paid_users = max(users_with_subscription - trial_users, 0) + trial_subscription_query = ( + select(func.count(Subscription.id)) + .join(User, Subscription.user_id == User.id) + .where( + User.status == UserStatus.ACTIVE.value, + Subscription.end_date > current_time, + or_( + Subscription.status == SubscriptionStatus.TRIAL.value, + Subscription.is_trial.is_(True), + ), + ) + ) + trial_users = (await db.execute(trial_subscription_query)).scalar() or 0 users_without_subscription = max( stats["active_users"] - users_with_subscription, 0, ) - + avg_balance_result = await db.execute( select(func.avg(User.balance_kopeks)) .where(User.status == UserStatus.ACTIVE.value) ) avg_balance = avg_balance_result.scalar() or 0 - avg_balance_kopeks = int(round(avg_balance)) - - conversion_percent = (paid_users / max(stats["active_users"], 1) * 100) - trial_share_percent = (trial_users / max(users_with_subscription, 1) * 100) - + text = f""" 📊 Детальная статистика пользователей @@ -937,7 +960,7 @@ async def show_users_statistics( • Без подписки: {users_without_subscription} 💰 Финансы: -• Средний баланс: {settings.format_price(avg_balance_kopeks)} +• Средний баланс: {settings.format_price(int(avg_balance))} 📈 Регистрации: • Сегодня: {stats['new_today']} @@ -945,8 +968,8 @@ async def show_users_statistics( • За месяц: {stats['new_month']} 📊 Активность: -• Конверсия в подписку: {conversion_percent:.1f}% -• Доля триальных: {trial_share_percent:.1f}% +• Конверсия в подписку: {(users_with_subscription / max(stats['active_users'], 1) * 100):.1f}% +• Доля триальных: {(trial_users / max(users_with_subscription, 1) * 100):.1f}% """ await callback.message.edit_text( From ab1b8619f8efd178bb58bba52bd8cdf90069e960 Mon Sep 17 00:00:00 2001 From: SantaSpeen Date: Tue, 30 Sep 2025 14:26:22 +0300 Subject: [PATCH 04/24] [+] Update get_apps_for_device: Allow app-config.json just from panel --- app/handlers/subscription.py | 2063 +++++++++++++++++----------------- 1 file changed, 1050 insertions(+), 1013 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f0b21c87..ee7577f0 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -11,11 +11,11 @@ from typing import Dict, List, Any, Tuple, Optional from app.config import settings, PERIOD_PRICES, get_traffic_prices from app.states import SubscriptionStates from app.database.crud.subscription import ( - get_subscription_by_user_id, create_trial_subscription, + get_subscription_by_user_id, create_trial_subscription, create_paid_subscription, extend_subscription, add_subscription_traffic, add_subscription_devices, add_subscription_squad, update_subscription_autopay, - add_subscription_servers + add_subscription_servers ) from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions @@ -77,9 +77,9 @@ TRAFFIC_PRICES = get_traffic_prices() def _get_addon_discount_percent_for_user( - user: Optional[User], - category: str, - period_days_hint: Optional[int] = None, + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, ) -> int: if user is None: return 0 @@ -98,10 +98,10 @@ def _get_addon_discount_percent_for_user( def _apply_addon_discount( - user: Optional[User], - category: str, - amount: int, - period_days_hint: Optional[int] = None, + user: Optional[User], + category: str, + amount: int, + period_days_hint: Optional[int] = None, ) -> Dict[str, int]: percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) discounted_amount, discount_value = apply_percentage_discount(amount, percent) @@ -125,9 +125,9 @@ def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> def _apply_discount_to_monthly_component( - amount_per_month: int, - percent: int, - months: int, + amount_per_month: int, + percent: int, + months: int, ) -> Dict[str, int]: discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) @@ -142,9 +142,9 @@ def _apply_discount_to_monthly_component( async def _prepare_subscription_summary( - db_user: User, - data: Dict[str, Any], - texts, + db_user: User, + data: Dict[str, Any], + texts, ) -> Tuple[str, Dict[str, Any]]: from app.utils.pricing_utils import ( calculate_months_from_days, @@ -240,9 +240,9 @@ async def _prepare_subscription_summary( total_price = base_price + total_traffic_price + total_countries_price + total_devices_price discounted_monthly_additions = ( - traffic_component["discounted_per_month"] - + discounted_servers_price_per_month - + devices_component["discounted_per_month"] + traffic_component["discounted_per_month"] + + discounted_servers_price_per_month + + devices_component["discounted_per_month"] ) is_valid = validate_pricing_calculation( @@ -357,9 +357,9 @@ async def _prepare_subscription_summary( def _build_promo_group_discount_text( - db_user: User, - periods: Optional[List[int]] = None, - texts=None, + db_user: User, + periods: Optional[List[int]] = None, + texts=None, ) -> str: promo_group = getattr(db_user, "promo_group", None) @@ -452,15 +452,15 @@ def _build_subscription_period_prompt(db_user: User, texts) -> str: async def show_subscription_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): await db.refresh(db_user) - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription: await callback.message.edit_text( texts.SUBSCRIPTION_NONE, @@ -468,17 +468,17 @@ async def show_subscription_info( ) await callback.answer() return - + from app.database.crud.subscription import check_and_update_subscription_status subscription = await check_and_update_subscription_status(db, subscription) - + subscription_service = SubscriptionService() await subscription_service.sync_subscription_usage(db, subscription) - + await db.refresh(subscription) - + current_time = datetime.utcnow() - + if subscription.status == "expired" or subscription.end_date <= current_time: actual_status = "expired" status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла") @@ -542,7 +542,7 @@ async def show_subscription_info( "SUBSCRIPTION_TRAFFIC_LIMITED", "{used} / {limit} ГБ", ).format(used=used_traffic, limit=subscription.traffic_limit_gb) - + devices_used_str = "—" devices_list = [] devices_count = 0 @@ -551,10 +551,10 @@ async def show_subscription_info( if db_user.remnawave_uuid: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] devices_count = devices_info.get('total', 0) @@ -563,7 +563,7 @@ async def show_subscription_info( logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}") else: logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка получения устройств для отображения: {e}") devices_used_str = await get_current_devices_count(db_user) @@ -617,14 +617,14 @@ async def show_subscription_info( device_info = device_info[:32] + "..." message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if ( - subscription_link - and actual_status in ["trial_active", "paid_active"] - and not hide_subscription_link + subscription_link + and actual_status in ["trial_active", "paid_active"] + and not hide_subscription_link ): message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", @@ -634,7 +634,7 @@ async def show_subscription_info( "SUBSCRIPTION_CONNECT_LINK_PROMPT", "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", ) - + await callback.message.edit_text( message, reply_markup=get_subscription_keyboard( @@ -647,43 +647,45 @@ async def show_subscription_info( ) await callback.answer() + async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: return {"count": 0, "devices": []} - + from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) - + return { "count": total_devices, - "devices": devices_list[:5] + "devices": devices_list[:5] } else: return {"count": 0, "devices": []} - + except Exception as e: logger.error(f"Ошибка получения детальной информации об устройствах: {e}") return {"count": 0, "devices": []} + async def get_servers_display_names(squad_uuids: List[str]) -> str: if not squad_uuids: return "Нет серверов" - + try: from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_server_squad_by_uuid - + server_names = [] - + async with AsyncSessionLocal() as db: for uuid in squad_uuids: server = await get_server_squad_by_uuid(db, uuid) @@ -692,7 +694,7 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}") else: logger.warning(f"Сервер с UUID {uuid} не найден в БД") - + if not server_names: countries = await _get_available_countries() for uuid in squad_uuids: @@ -701,42 +703,43 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: server_names.append(country['name']) logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}") break - + if not server_names: if len(squad_uuids) == 1: return "🎯 Тестовый сервер" return f"{len(squad_uuids)} стран" - + if len(server_names) > 6: displayed = ", ".join(server_names[:6]) remaining = len(server_names) - 6 return f"{displayed} и ещё {remaining}" else: return ", ".join(server_names) - + except Exception as e: logger.error(f"Ошибка получения названий серверов: {e}") if len(squad_uuids) == 1: return "🎯 Тестовый сервер" return f"{len(squad_uuids)} стран" + async def get_current_devices_count(db_user: User) -> str: try: if not db_user.remnawave_uuid: return "—" - + from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: total_devices = response['response'].get('total', 0) return str(total_devices) else: return "—" - + except Exception as e: logger.error(f"Ошибка получения количества устройств: {e}") return "—" @@ -746,12 +749,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: try: if subscription.is_trial: return 0 - + from app.config import settings from app.services.subscription_service import SubscriptionService - + subscription_service = SubscriptionService() - + base_cost_original = PERIOD_PRICES.get(30, 0) try: owner = subscription.user @@ -786,43 +789,43 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: db, promo_group_id=promo_group_id, ) - + traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - + total_cost = base_cost + servers_cost + traffic_cost + devices_cost - + logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:") - base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽" + base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽" if period_discount_percent > 0: discount_value = base_cost_original * period_discount_percent // 100 base_log += ( - f" → {base_cost/100}₽" - f" (скидка {period_discount_percent}%: -{discount_value/100}₽)" + f" → {base_cost / 100}₽" + f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)" ) logger.info(base_log) if servers_cost > 0: - logger.info(f" 🌍 Серверы: {servers_cost/100}₽") + logger.info(f" 🌍 Серверы: {servers_cost / 100}₽") if traffic_cost > 0: - logger.info(f" 📊 Трафик: {traffic_cost/100}₽") + logger.info(f" 📊 Трафик: {traffic_cost / 100}₽") if devices_cost > 0: - logger.info(f" 📱 Устройства: {devices_cost/100}₽") - logger.info(f" 💎 ИТОГО: {total_cost/100}₽") - + logger.info(f" 📱 Устройства: {devices_cost / 100}₽") + logger.info(f" 💎 ИТОГО: {total_cost / 100}₽") + return total_cost - + except Exception as e: logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}") return 0 async def show_trial_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) - + if db_user.subscription or db_user.has_had_paid_subscription: await callback.message.edit_text( texts.TRIAL_ALREADY_USED, @@ -830,11 +833,11 @@ async def show_trial_offer( ) await callback.answer() return - - trial_server_name = "🎯 Тестовый сервер" + + trial_server_name = "🎯 Тестовый сервер" try: from app.database.crud.server_squad import get_server_squad_by_uuid - + if settings.TRIAL_SQUAD_UUID: trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID) if trial_server: @@ -843,17 +846,17 @@ async def show_trial_offer( logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД") else: logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации") - + except Exception as e: logger.error(f"Ошибка получения триального сервера: {e}") - + trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, devices=settings.TRIAL_DEVICE_LIMIT, server_name=trial_server_name ) - + await callback.message.edit_text( trial_text, reply_markup=get_trial_keyboard(db_user.language) @@ -862,14 +865,14 @@ async def show_trial_offer( async def activate_trial( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.services.admin_notification_service import AdminNotificationService - + texts = get_texts(db_user.language) - + if db_user.subscription or db_user.has_had_paid_subscription: await callback.message.edit_text( texts.TRIAL_ALREADY_USED, @@ -877,54 +880,54 @@ async def activate_trial( ) await callback.answer() return - + try: subscription = await create_trial_subscription(db, db_user.id) - + await db.refresh(db_user) - + subscription_service = SubscriptionService() remnawave_user = await subscription_service.create_remnawave_user( db, subscription ) - + await db.refresh(db_user) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) elif hide_subscription_link: trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) else: subscription_import_link = texts.t( @@ -948,7 +951,8 @@ async def activate_trial( web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": if not settings.MINIAPP_CUSTOM_URL: @@ -968,7 +972,8 @@ async def activate_trial( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "link": rows = [ @@ -1005,10 +1010,12 @@ async def activate_trial( connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) - + await callback.message.edit_text( trial_success_text, reply_markup=connect_keyboard, @@ -1019,23 +1026,23 @@ async def activate_trial( f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка активации триала: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def start_subscription_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): texts = get_texts(db_user.language) @@ -1056,27 +1063,28 @@ async def start_subscription_purchase( 'devices': initial_devices, 'total_price': 0 } - + if settings.is_traffic_fixed(): initial_data['traffic_gb'] = settings.get_fixed_traffic_limit() else: initial_data['traffic_gb'] = None - + await state.set_data(initial_data) 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 + 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, @@ -1084,7 +1092,7 @@ async def save_cart_and_redirect_to_topup( 'missing_amount': missing_amount, 'return_to_cart': True }) - + await callback.message.edit_text( f"💰 Недостаточно средств для оформления подписки\n\n" f"Требуется: {texts.format_price(missing_amount)}\n" @@ -1099,21 +1107,22 @@ async def save_cart_and_redirect_to_topup( parse_mode="HTML" ) + async def return_to_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + 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( @@ -1127,24 +1136,24 @@ async def return_to_saved_cart( ) ) return - + from app.utils.pricing_utils import calculate_months_from_days, format_period_description - + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] - + months_in_period = calculate_months_from_days(data['period_days']) period_display = format_period_description(data['period_days'], db_user.language) - + 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" @@ -1154,26 +1163,27 @@ async def return_to_saved_cart( 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, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): if not await _should_show_countries_management(db_user): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1190,28 +1200,28 @@ async def handle_add_countries( "servers", period_hint_days, ) - + current_countries_names = [] for country in countries: if country['uuid'] in current_countries: current_countries_names.append(country['name']) - + text = "🌍 Управление странами подписки\n\n" text += f"📋 Текущие страны ({len(current_countries)}):\n" if current_countries_names: text += "\n".join(f"• {name}" for name in current_countries_names) else: text += "Нет подключенных стран" - + text += "\n\n💡 Инструкция:\n" text += "✅ - страна подключена\n" text += "➕ - будет добавлена (платно)\n" text += "➖ - будет отключена (бесплатно)\n" text += "⚪ - не выбрана\n\n" text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" - + await state.update_data(countries=current_countries.copy()) - + await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( @@ -1224,20 +1234,21 @@ async def handle_add_countries( ), parse_mode="HTML" ) - + await callback.answer() + async def get_countries_price_by_uuids_fallback( - country_uuids: List[str], - db: AsyncSession, - promo_group_id: Optional[int] = None, + country_uuids: List[str], + db: AsyncSession, + promo_group_id: Optional[int] = None, ) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid - + total_price = 0 prices_list = [] - + for country_uuid in country_uuids: try: server = await get_server_squad_by_uuid(db, country_uuid) @@ -1258,24 +1269,25 @@ async def get_countries_price_by_uuids_fallback( default_price = 0 total_price += default_price prices_list.append(default_price) - + return total_price, prices_list - + except Exception as e: logger.error(f"Ошибка fallback функции: {e}") default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices + async def handle_manage_country( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): logger.info(f"🔍 Управление страной: {callback.data}") - - country_uuid = callback.data.split('_')[2] - + + country_uuid = callback.data.split('_')[2] + subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer("⚠ Только для платных подписок", show_alert=True) @@ -1321,22 +1333,23 @@ async def handle_manage_country( ) ) logger.info(f"✅ Клавиатура обновлена") - + except Exception as e: logger.error(f"⚠ Ошибка обновления клавиатуры: {e}") - + await callback.answer() + async def apply_countries_changes( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - + logger.info(f"🔧 Применение изменений стран") - + data = await state.get_data() texts = get_texts(db_user.language) @@ -1347,7 +1360,7 @@ async def apply_countries_changes( else None ) subscription = db_user.subscription - + selected_countries = data.get('countries', []) current_countries = subscription.connected_squads @@ -1426,7 +1439,7 @@ async def apply_countries_changes( total_cost / 100, total_discount / 100, ) - + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" @@ -1456,17 +1469,17 @@ async def apply_countries_changes( ) await callback.answer() return - + try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -1474,28 +1487,29 @@ async def apply_countries_changes( amount_kopeks=total_cost, description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес" ) - + if added: from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers from app.database.crud.subscription import add_subscription_servers - + added_server_ids = await get_server_ids_by_uuids(db, added) - + if added_server_ids: await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) await add_user_to_servers(db, added_server_ids) - - logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") - + + logger.info( + f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") + subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -1504,9 +1518,9 @@ async def apply_countries_changes( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}") - + success_text = "✅ Страны успешно обновлены!\n\n" - + if added_names: success_text += f"➕ Добавлены страны:\n" success_text += "\n".join(f"• {name}" for name in added_names) @@ -1518,55 +1532,56 @@ async def apply_countries_changes( f" -{texts.format_price(total_discount)})" ) success_text += "\n" - + if removed_names: success_text += f"\n➖ Отключены страны:\n" success_text += "\n".join(f"• {name}" for name in removed_names) success_text += "\nℹ️ Повторное подключение будет платным\n" - + success_text += f"\n🌐 Активных стран: {len(selected_countries)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) - + await state.clear() - logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽") - + logger.info( + f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽") + except Exception as e: logger.error(f"⚠️ Ошибка применения изменений: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def handle_add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - + if subscription.traffic_limit_gb == 0: await callback.answer("⚠ У вас уже безлимитный трафик", show_alert=True) return - + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -1586,22 +1601,22 @@ async def handle_add_traffic( ), parse_mode="HTML" ) - + await callback.answer() - + async def handle_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) @@ -1626,44 +1641,45 @@ async def handle_change_devices( ), parse_mode="HTML" ) - + await callback.answer() + async def confirm_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - + new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + current_devices = subscription.device_limit - + if new_devices_count == current_devices: await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True) return - + if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})", show_alert=True ) return - + devices_difference = new_devices_count - current_devices - - if devices_difference > 0: + + if devices_difference > 0: additional_devices = devices_difference - + if current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None @@ -1681,7 +1697,7 @@ async def confirm_change_devices( subscription.end_date, ) total_discount = discount_per_month * charged_months - + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -1710,7 +1726,7 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" if price > 0: cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" @@ -1721,54 +1737,54 @@ async def confirm_change_devices( ) else: cost_text = "Бесплатно" - - else: + + else: price = 0 action_text = f"уменьшить до {new_devices_count}" cost_text = "Возврат средств не производится" - + confirm_text = f"📱 Подтверждение изменения\n\n" confirm_text += f"Текущее количество: {current_devices} устройств\n" confirm_text += f"Новое количество: {new_devices_count} устройств\n\n" confirm_text += f"Действие: {action_text}\n" confirm_text += f"💰 {cost_text}\n\n" confirm_text += "Подтвердить изменение?" - + await callback.message.edit_text( confirm_text, reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language), parse_mode="HTML" ) - + await callback.answer() async def execute_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - + callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) - + texts = get_texts(db_user.language) subscription = db_user.subscription current_devices = subscription.device_limit - + try: if price > 0: success = await subtract_user_balance( db, db_user, price, f"Изменение количества устройств с {current_devices} до {new_devices_count}" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -1777,18 +1793,18 @@ async def execute_change_devices( amount_kopeks=price, description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес" ) - + subscription.device_limit = new_devices_count subscription.updated_at = datetime.utcnow() - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -1797,7 +1813,7 @@ async def execute_change_devices( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}") - + if new_devices_count > current_devices: success_text = f"✅ Количество устройств увеличено!\n\n" success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" @@ -1807,52 +1823,53 @@ async def execute_change_devices( success_text = f"✅ Количество устройств уменьшено!\n\n" success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" success_text += f"ℹ️ Возврат средств не производится" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price/100}₽") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽") + except Exception as e: logger.error(f"Ошибка изменения количества устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() + async def handle_device_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + if not db_user.remnawave_uuid: await callback.answer("❌ UUID пользователя не найден", show_alert=True) return - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) - + if total_devices == 0: await callback.message.edit_text( "ℹ️ У вас нет подключенных устройств", @@ -1860,57 +1877,56 @@ async def handle_device_management( ) await callback.answer() return - + await show_devices_page(callback, db_user, devices_list, page=1) else: await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) - + except Exception as e: logger.error(f"Ошибка получения списка устройств: {e}") await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) - + await callback.answer() async def show_devices_page( - callback: types.CallbackQuery, - db_user: User, - devices_list: List[dict], - page: int = 1 + callback: types.CallbackQuery, + db_user: User, + devices_list: List[dict], + page: int = 1 ): - from app.utils.pagination import paginate_list - + texts = get_texts(db_user.language) devices_per_page = 5 - + pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - + devices_text = f"🔄 Управление устройствами\n\n" devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n" devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n" - + if pagination.items: devices_text += "Подключенные устройства:\n" for i, device in enumerate(pagination.items, 1): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" - + if len(device_info) > 35: device_info = device_info[:32] + "..." - + devices_text += f"• {device_info}\n" - + devices_text += "\n💡 Действия:\n" devices_text += "• Выберите устройство для сброса\n" devices_text += "• Или сбросьте все устройства сразу" - + await callback.message.edit_text( devices_text, reply_markup=get_devices_management_keyboard( - pagination.items, - pagination, + pagination.items, + pagination, db_user.language ), parse_mode="HTML" @@ -1918,104 +1934,103 @@ async def show_devices_page( async def handle_devices_page( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - page = int(callback.data.split('_')[2]) - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_list = response['response'].get('devices', []) await show_devices_page(callback, db_user, devices_list, page=page) else: await callback.answer("❌ Ошибка получения устройств", show_alert=True) - + except Exception as e: logger.error(f"Ошибка перехода на страницу устройств: {e}") await callback.answer("❌ Ошибка загрузки страницы", show_alert=True) async def handle_single_device_reset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - try: callback_parts = callback.data.split('_') if len(callback_parts) < 4: logger.error(f"Некорректный формат callback_data: {callback.data}") await callback.answer("❌ Ошибка: некорректный запрос", show_alert=True) return - + device_index = int(callback_parts[2]) page = int(callback_parts[3]) - + logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}") - + except (ValueError, IndexError) as e: logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}") await callback.answer("❌ Ошибка обработки запроса", show_alert=True) return - + texts = get_texts(db_user.language) - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_list = response['response'].get('devices', []) - + from app.utils.pagination import paginate_list devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - + if device_index < len(pagination.items): device = pagination.items[device_index] device_hwid = device.get('hwid') - + if device_hwid: delete_data = { "userUuid": db_user.remnawave_uuid, "hwid": device_hwid } - + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) - + platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" - + await callback.answer(f"✅ Устройство {device_info} успешно сброшено!", show_alert=True) - + updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if updated_response and 'response' in updated_response: updated_devices = updated_response['response'].get('devices', []) - + if updated_devices: - updated_pagination = paginate_list(updated_devices, page=page, per_page=devices_per_page) + updated_pagination = paginate_list(updated_devices, page=page, + per_page=devices_per_page) if not updated_pagination.items and page > 1: page = page - 1 - + await show_devices_page(callback, db_user, updated_devices, page=page) else: await callback.message.edit_text( "ℹ️ Все устройства сброшены", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}") else: await callback.answer("❌ Не удалось получить ID устройства", show_alert=True) @@ -2023,46 +2038,45 @@ async def handle_single_device_reset( await callback.answer("❌ Устройство не найдено", show_alert=True) else: await callback.answer("❌ Ошибка получения устройств", show_alert=True) - + except Exception as e: logger.error(f"Ошибка сброса устройства: {e}") await callback.answer("❌ Ошибка сброса устройства", show_alert=True) async def handle_all_devices_reset_from_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - texts = get_texts(db_user.language) - + if not db_user.remnawave_uuid: await callback.answer("❌ UUID пользователя не найден", show_alert=True) return - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if not devices_response or 'response' not in devices_response: await callback.answer("❌ Ошибка получения списка устройств", show_alert=True) return - + devices_list = devices_response['response'].get('devices', []) - + if not devices_list: await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True) return - + logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса") - + success_count = 0 failed_count = 0 - + for device in devices_list: device_hwid = device.get('hwid') if device_hwid: @@ -2071,18 +2085,18 @@ async def handle_all_devices_reset_from_management( "userUuid": db_user.remnawave_uuid, "hwid": device_hwid } - + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) success_count += 1 logger.info(f"✅ Устройство {device_hwid} удалено") - + except Exception as device_error: failed_count += 1 logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}") else: failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") - + if success_count > 0: if failed_count == 0: await callback.message.edit_text( @@ -2103,7 +2117,8 @@ async def handle_all_devices_reset_from_management( reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) - logger.warning(f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") + logger.warning( + f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") else: await callback.message.edit_text( f"❌ Не удалось сбросить устройства\n\n" @@ -2113,38 +2128,38 @@ async def handle_all_devices_reset_from_management( parse_mode="HTML" ) logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка сброса всех устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def handle_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True) return - + subscription_service = SubscriptionService() - + available_periods = settings.get_available_renewal_periods() renewal_prices = {} - + for days in available_periods: try: months_in_period = calculate_months_from_days(days) - + from app.config import PERIOD_PRICES from app.utils.pricing_utils import apply_percentage_discount @@ -2154,7 +2169,7 @@ async def handle_extend_subscription( base_price_original, period_discount_percent, ) - + servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db, @@ -2186,15 +2201,15 @@ async def handle_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price renewal_prices[days] = price - + except Exception as e: logger.error(f"Ошибка расчета цены для периода {days}: {e}") continue - + if not renewal_prices: await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True) return - + prices_text = "" for days in available_periods: @@ -2229,38 +2244,38 @@ async def handle_extend_subscription( reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), parse_mode="HTML" ) - + await callback.answer() async def handle_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True) return - + if subscription.traffic_limit_gb == 0: await callback.answer("⌛ У вас безлимитный трафик", show_alert=True) return - + reset_price = PERIOD_PRICES[30] - + if db_user.balance_kopeks < reset_price: await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True) return - + await callback.message.edit_text( f"🔄 Сброс трафика\n\n" f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n" @@ -2269,7 +2284,7 @@ async def handle_reset_traffic( "После сброса счетчик использованного трафика станет равным 0.", reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language) ) - + await callback.answer() @@ -2280,20 +2295,20 @@ def update_traffic_prices(): async def confirm_add_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - + devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription resume_callback = None - + new_total_devices = subscription.device_limit + devices_count - + if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT: await callback.answer( f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). " @@ -2301,7 +2316,7 @@ async def confirm_add_devices( show_alert=True ) return - + devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None @@ -2328,7 +2343,7 @@ async def confirm_add_devices( price / 100, total_discount / 100, ) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -2358,22 +2373,22 @@ async def confirm_add_devices( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, price, f"Добавление {devices_count} устройств на {charged_months} мес" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await add_subscription_devices(db, subscription, devices_count) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -2381,11 +2396,10 @@ async def confirm_add_devices( amount_kopeks=price, description=f"Добавление {devices_count} устройств на {charged_months} мес" ) - - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = ( "✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" @@ -2402,23 +2416,23 @@ async def confirm_add_devices( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price/100}₽") - + + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽") + except Exception as e: logger.error(f"Ошибка добавления устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def confirm_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import ( calculate_months_from_days, @@ -2471,7 +2485,7 @@ async def confirm_extend_subscription( server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period discounted_servers_price_per_month = servers_price_per_month - ( - servers_price_per_month * servers_discount_percent // 100 + servers_price_per_month * servers_discount_percent // 100 ) additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) @@ -2496,9 +2510,9 @@ async def confirm_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price monthly_additions = ( - discounted_servers_price_per_month - + discounted_devices_price_per_month - + discounted_traffic_price_per_month + discounted_servers_price_per_month + + discounted_devices_price_per_month + + discounted_traffic_price_per_month ) is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price) @@ -2508,47 +2522,47 @@ async def confirm_extend_subscription( return logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - base_log = f" 📅 Период {days} дней: {base_price_original/100}₽" + base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽" if base_discount_total > 0: base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + f" → {base_price / 100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)" ) logger.info(base_log) if total_servers_price > 0: logger.info( - f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_servers_price/100}₽" + f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_servers_price / 100}₽" + ( f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount/100}₽)" + f" -{total_servers_discount / 100}₽)" if total_servers_discount > 0 else "" ) ) if total_devices_price > 0: logger.info( - f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_devices_price/100}₽" + f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_devices_price / 100}₽" + ( f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_per_month * months_in_period/100}₽)" + f" -{devices_discount_per_month * months_in_period / 100}₽)" if devices_discount_percent > 0 and devices_discount_per_month > 0 else "" ) ) if total_traffic_price > 0: logger.info( - f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_traffic_price/100}₽" + f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_traffic_price / 100}₽" + ( f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_per_month * months_in_period/100}₽)" + f" -{traffic_discount_per_month * months_in_period / 100}₽)" if traffic_discount_percent > 0 and traffic_discount_per_month > 0 else "" ) ) - logger.info(f" 💎 ИТОГО: {price/100}₽") + logger.info(f" 💎 ИТОГО: {price / 100}₽") except Exception as e: logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") @@ -2683,7 +2697,7 @@ async def confirm_extend_subscription( reply_markup=get_back_keyboard(db_user.language) ) - logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽") + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽") except Exception as e: logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") @@ -2699,19 +2713,19 @@ async def confirm_extend_subscription( async def confirm_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + reset_price = PERIOD_PRICES[30] if db_user.balance_kopeks < reset_price: @@ -2741,29 +2755,29 @@ async def confirm_reset_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, reset_price, "Сброс трафика" ) - + if not success: await callback.answer("⌛ Ошибка списания средств", show_alert=True) return - + subscription.traffic_used_gb = 0.0 subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() remnawave_service = RemnaWaveService() - + user = db_user if user.remnawave_uuid: async with remnawave_service.get_api_client() as api: await api.reset_user_traffic(user.remnawave_uuid) - + await create_transaction( db=db, user_id=db_user.id, @@ -2771,56 +2785,55 @@ async def confirm_reset_traffic( amount_kopeks=reset_price, description="Сброс трафика" ) - + await db.refresh(db_user) await db.refresh(subscription) - + await callback.message.edit_text( f"✅ Трафик успешно сброшен!\n\n" f"🔄 Использованный трафик обнулен\n" f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик") - + except Exception as e: logger.error(f"Ошибка сброса трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() - async def select_period( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): period_days = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) - + data = await state.get_data() data['period_days'] = period_days data['total_price'] = PERIOD_PRICES[period_days] - + if settings.is_traffic_fixed(): fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) data['total_price'] += fixed_traffic_price data['traffic_gb'] = settings.get_fixed_traffic_limit() - + await state.set_data(data) - + if settings.is_traffic_selectable(): available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']] - + if not available_packages: await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True) return - + await callback.message.edit_text( texts.SELECT_TRAFFIC, reply_markup=get_traffic_packages_keyboard(db_user.language) @@ -2839,7 +2852,7 @@ async def select_period( available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( @@ -2847,67 +2860,69 @@ async def select_period( reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) - + await callback.answer() + async def refresh_traffic_config(): try: from app.config import refresh_traffic_prices refresh_traffic_prices() - + packages = settings.get_traffic_packages() enabled_count = sum(1 for pkg in packages if pkg['enabled']) - + logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов") for pkg in packages: if pkg['enabled']: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - logger.info(f" 📦 {gb_text}: {pkg['price']/100}₽") - + logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽") + return True - + except Exception as e: logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}") return False + async def get_traffic_packages_info() -> str: try: packages = settings.get_traffic_packages() - + info_lines = ["📦 Настроенные пакеты трафика:"] - + enabled_packages = [pkg for pkg in packages if pkg['enabled']] disabled_packages = [pkg for pkg in packages if not pkg['enabled']] - + if enabled_packages: info_lines.append("\n✅ Активные:") for pkg in enabled_packages: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price']//100}₽") - + info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + if disabled_packages: info_lines.append("\n❌ Отключенные:") for pkg in disabled_packages: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price']//100}₽") - + info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + info_lines.append(f"\n📊 Всего пакетов: {len(packages)}") info_lines.append(f"🟢 Активных: {len(enabled_packages)}") info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}") - + return "\n".join(info_lines) - + except Exception as e: return f"⚠️ Ошибка получения информации: {e}" + async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): - devices_used = await get_current_devices_count(db_user) countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" - + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." - + if subscription.is_trial: status_text = "🎁 Тестовая" type_text = "Триал" @@ -2917,7 +2932,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess else: status_text = "⌛ Истекла" type_text = "Платная подписка" - + if subscription.traffic_limit_gb == 0: if settings.is_traffic_fixed(): traffic_text = "∞ Безлимитный" @@ -2928,9 +2943,9 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess traffic_text = f"{subscription.traffic_limit_gb} ГБ" else: traffic_text = f"{subscription.traffic_limit_gb} ГБ" - + subscription_cost = await get_subscription_cost(subscription, db) - + info_text = texts.SUBSCRIPTION_INFO.format( status=status_text, type=type_text, @@ -2943,23 +2958,24 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess devices_limit=subscription.device_limit, autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен" ) - + if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" - + if ( - subscription_url - and subscription_url != "Генерируется..." - and not settings.should_hide_subscription_link() + subscription_url + and subscription_url != "Генерируется..." + and not settings.should_hide_subscription_link() ): info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" return info_text + def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: if is_fixed_mode is None: is_fixed_mode = settings.is_traffic_fixed() - + if traffic_gb == 0: if is_fixed_mode: return "Безлимитный" @@ -2971,22 +2987,23 @@ def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: else: return f"{traffic_gb} ГБ" + async def select_traffic( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): traffic_gb = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) - + data = await state.get_data() data['traffic_gb'] = traffic_gb - + traffic_price = settings.get_traffic_price(traffic_gb) data['total_price'] += traffic_price - + await state.set_data(data) - + if await _should_show_countries_management(db_user): countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( @@ -2999,7 +3016,7 @@ async def select_traffic( available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( @@ -3007,32 +3024,32 @@ async def select_traffic( reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) - + await callback.answer() async def select_country( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): country_uuid = callback.data.split('_')[1] data = await state.get_data() - + selected_countries = data.get('countries', []) if country_uuid in selected_countries: selected_countries.remove(country_uuid) else: selected_countries.append(country_uuid) - + countries = await _get_available_countries(db_user.promo_group_id) allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return - + period_base_price = PERIOD_PRICES[data['period_days']] from app.utils.pricing_utils import apply_percentage_discount @@ -3042,7 +3059,7 @@ async def select_country( ) base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) - + try: subscription_service = SubscriptionService() countries_price, _ = await subscription_service.get_countries_price_by_uuids( @@ -3057,11 +3074,11 @@ async def select_country( db, promo_group_id=db_user.promo_group_id, ) - + data['countries'] = selected_countries data['total_price'] = base_price + countries_price await state.set_data(data) - + await callback.message.edit_reply_markup( reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) ) @@ -3069,73 +3086,73 @@ async def select_country( async def countries_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): - data = await state.get_data() texts = get_texts(db_user.language) - + if not data.get('countries'): await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True) return - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) - + await state.set_state(SubscriptionStates.selecting_devices) await callback.answer() async def select_devices( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): if not callback.data.startswith("devices_") or callback.data == "devices_continue": await callback.answer("❌ Некорректный запрос", show_alert=True) return - + try: devices = int(callback.data.split('_')[1]) except (ValueError, IndexError): await callback.answer("❌ Некорректное количество устройств", show_alert=True) return - + data = await state.get_data() - + base_price = ( - PERIOD_PRICES[data['period_days']] + - settings.get_traffic_price(data['traffic_gb']) + PERIOD_PRICES[data['period_days']] + + settings.get_traffic_price(data['traffic_gb']) ) - + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( - c['price_kopeks'] for c in countries + c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] ) - + devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - + data['devices'] = devices data['total_price'] = base_price + countries_price + devices_price await state.set_data(data) - + await callback.message.edit_reply_markup( reply_markup=get_devices_keyboard(devices, db_user.language) ) await callback.answer() + async def devices_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) @@ -3165,14 +3182,14 @@ async def devices_continue( async def confirm_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation from app.services.admin_notification_service import AdminNotificationService - + data = await state.get_data() texts = get_texts(db_user.language) @@ -3184,7 +3201,7 @@ async def confirm_purchase( ) countries = await _get_available_countries(db_user.promo_group_id) - + months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) ) @@ -3338,55 +3355,55 @@ async def confirm_purchase( months_in_period, final_price, ) - + if not is_valid: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - + logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") - base_log = f" Период: {base_price_original/100}₽" + base_log = f" Период: {base_price_original / 100}₽" if base_discount_total and base_discount_total > 0: base_log += ( - f" → {base_price/100}₽" - f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)" + f" → {base_price / 100}₽" + f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)" ) logger.info(base_log) if total_traffic_price > 0: message = ( - f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_traffic_price/100}₽" + f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_traffic_price / 100}₽" ) if traffic_discount_total > 0: message += ( f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_total/100}₽)" + f" -{traffic_discount_total / 100}₽)" ) logger.info(message) if total_servers_price > 0: message = ( - f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_servers_price/100}₽" + f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_servers_price / 100}₽" ) if total_servers_discount > 0: message += ( f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount/100}₽)" + f" -{total_servers_discount / 100}₽)" ) logger.info(message) if total_devices_price > 0: message = ( - f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_devices_price/100}₽" + f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_devices_price / 100}₽" ) if devices_discount_total > 0: message += ( f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_total/100}₽)" + f" -{devices_discount_total / 100}₽)" ) logger.info(message) - logger.info(f" ИТОГО: {final_price/100}₽") - + logger.info(f" ИТОГО: {final_price / 100}₽") + if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( @@ -3415,7 +3432,7 @@ async def confirm_purchase( ) await callback.answer() return - + purchase_completed = False try: @@ -3423,7 +3440,7 @@ async def confirm_purchase( db, db_user, final_price, f"Покупка подписки на {data['period_days']} дней" ) - + if not success: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( @@ -3452,7 +3469,7 @@ async def confirm_purchase( ) await callback.answer() return - + existing_subscription = db_user.subscription was_trial_conversion = False current_time = datetime.utcnow() @@ -3488,10 +3505,11 @@ async def confirm_purchase( first_payment_amount_kopeks=final_price, first_paid_period_days=data['period_days'] ) - logger.info(f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price/100}₽") + logger.info( + f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽") except Exception as conversion_error: logger.error(f"Ошибка записи конверсии: {conversion_error}") - + existing_subscription.is_trial = False existing_subscription.status = SubscriptionStatus.ACTIVE.value existing_subscription.traffic_limit_gb = final_traffic_gb @@ -3507,7 +3525,7 @@ async def confirm_purchase( await db.commit() await db.refresh(existing_subscription) subscription = existing_subscription - + else: logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") subscription = await create_paid_subscription_with_traffic_mode( @@ -3518,25 +3536,25 @@ async def confirm_purchase( connected_squads=data['countries'], traffic_gb=final_traffic_gb ) - + from app.utils.user_utils import mark_user_as_had_paid_subscription await mark_user_as_had_paid_subscription(db, db_user) - + from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers from app.database.crud.subscription import add_subscription_servers - + server_ids = await get_server_ids_by_uuids(db, data['countries']) - + if server_ids: await add_subscription_servers(db, subscription, server_ids, server_prices) await add_user_to_servers(db, server_ids) - + logger.info(f"Сохранены цены серверов за весь период: {server_prices}") - + await db.refresh(db_user) - + subscription_service = SubscriptionService() - + if db_user.remnawave_uuid: remnawave_user = await subscription_service.update_remnawave_user( db, @@ -3551,7 +3569,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки", ) - + if not remnawave_user: logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") remnawave_user = await subscription_service.create_remnawave_user( @@ -3560,7 +3578,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки (повторная попытка)", ) - + transaction = await create_transaction( db=db, user_id=db_user.id, @@ -3568,7 +3586,7 @@ async def confirm_purchase( amount_kopeks=final_price, description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" ) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_purchase_notification( @@ -3576,39 +3594,39 @@ async def confirm_purchase( ) except Exception as e: logger.error(f"Ошибка отправки уведомления о покупке: {e}") - + await db.refresh(db_user) await db.refresh(subscription) - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) elif hide_subscription_link: success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) else: import_link_section = texts.t( @@ -3632,7 +3650,8 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": if not settings.MINIAPP_CUSTOM_URL: @@ -3652,7 +3671,8 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "link": rows = [ @@ -3661,7 +3681,8 @@ async def confirm_purchase( happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) elif connect_mode == "happ_cryptolink": rows = [ @@ -3675,14 +3696,17 @@ async def confirm_purchase( happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) - + await callback.message.edit_text( success_text, reply_markup=connect_keyboard, @@ -3696,17 +3720,18 @@ async def confirm_purchase( ).format(purchase_text=texts.SUBSCRIPTION_PURCHASED), reply_markup=get_back_keyboard(db_user.language) ) - + purchase_completed = True - logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") - + logger.info( + f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽") + except Exception as e: logger.error(f"Ошибка покупки подписки: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + if purchase_completed: await clear_subscription_checkout_draft(db_user.id) @@ -3714,11 +3739,10 @@ async def confirm_purchase( await callback.answer() - async def resume_subscription_checkout( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, ): texts = get_texts(db_user.language) @@ -3749,19 +3773,21 @@ async def resume_subscription_checkout( ) await callback.answer() + + async def add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + base_price = settings.get_traffic_price(traffic_gb) if base_price == 0 and traffic_gb != 0: @@ -3817,7 +3843,7 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, @@ -3825,19 +3851,19 @@ async def add_traffic( price, f"Добавление {traffic_gb} ГБ трафика", ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - - if traffic_gb == 0: + + if traffic_gb == 0: subscription.traffic_limit_gb = 0 else: await add_subscription_traffic(db, subscription, traffic_gb) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -3845,11 +3871,10 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика", ) - - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" @@ -3869,37 +3894,38 @@ async def add_traffic( success_text, reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") - + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() + async def create_paid_subscription_with_traffic_mode( - db: AsyncSession, - user_id: int, - duration_days: int, - device_limit: int, - connected_squads: List[str], - traffic_gb: Optional[int] = None + db: AsyncSession, + user_id: int, + duration_days: int, + device_limit: int, + connected_squads: List[str], + traffic_gb: Optional[int] = None ): from app.config import settings from app.database.crud.subscription import create_paid_subscription - + if traffic_gb is None: if settings.is_traffic_fixed(): traffic_limit_gb = settings.get_fixed_traffic_limit() else: - traffic_limit_gb = 0 + traffic_limit_gb = 0 else: traffic_limit_gb = traffic_gb - + subscription = await create_paid_subscription( db=db, user_id=user_id, @@ -3908,35 +3934,36 @@ async def create_paid_subscription_with_traffic_mode( device_limit=device_limit, connected_squads=connected_squads ) - + logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") - + return subscription + def validate_traffic_price(gb: int) -> bool: from app.config import settings - + price = settings.get_traffic_price(gb) - if gb == 0: + if gb == 0: return True - + return price > 0 async def handle_subscription_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True) return - + devices_used = await get_current_devices_count(db_user) - + settings_text = f""" ⚙️ Настройки подписки @@ -3947,9 +3974,9 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - + show_countries = await _should_show_countries_management(db_user) - + await callback.message.edit_text( settings_text, reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries), @@ -3959,24 +3986,23 @@ async def handle_subscription_settings( async def handle_autopay_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription if not subscription: await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) return - + status = "включен" if subscription.autopay_enabled else "выключен" days = subscription.autopay_days_before - + text = f"💳 Автоплатеж\n\n" text += f"📊 Статус: {status}\n" text += f"⏰ Списание за: {days} дн. до окончания\n\n" text += "Выберите действие:" - + await callback.message.edit_text( text, reply_markup=get_autopay_keyboard(db_user.language) @@ -3985,27 +4011,25 @@ async def handle_autopay_menu( async def toggle_autopay( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription enable = callback.data == "autopay_enable" - + await update_subscription_autopay(db, subscription, enable) - + status = "включен" if enable else "выключен" await callback.answer(f"✅ Автоплатеж {status}!") - + await handle_autopay_menu(callback, db_user, db) async def show_autopay_days( - callback: types.CallbackQuery, - db_user: User + callback: types.CallbackQuery, + db_user: User ): - await callback.message.edit_text( "⏰ Выберите за сколько дней до окончания списывать средства:", reply_markup=get_autopay_days_keyboard(db_user.language) @@ -4014,31 +4038,31 @@ async def show_autopay_days( async def set_autopay_days( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - days = int(callback.data.split('_')[2]) subscription = db_user.subscription - + await update_subscription_autopay( db, subscription, subscription.autopay_enabled, days ) - + await callback.answer(f"✅ Установлено {days} дней!") - + await handle_autopay_menu(callback, db_user, db) + async def handle_subscription_config_back( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): current_state = await state.get_state() texts = get_texts(db_user.language) - + if current_state == SubscriptionStates.selecting_traffic.state: await callback.message.edit_text( _build_subscription_period_prompt(db_user, texts), @@ -4083,21 +4107,21 @@ async def handle_subscription_config_back( reply_markup=get_subscription_period_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_period) - + else: from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) await state.clear() - + await callback.answer() -async def handle_subscription_cancel( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_subscription_cancel( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): texts = get_texts(db_user.language) await state.clear() @@ -4108,6 +4132,7 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") + async def _get_available_countries(promo_group_id: Optional[int] = None): from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal @@ -4136,23 +4161,24 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): for server in available_servers: countries.append({ "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, "is_available": server.is_available and not server.is_full }) - + if not countries: logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") from app.services.remnawave_service import RemnaWaveService - + service = RemnaWaveService() squads = await service.get_all_squads() - + for squad in squads: squad_name = squad["name"] - - if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): + + if not any(flag in squad_name for flag in + ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): name_lower = squad_name.lower() if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: squad_name = f"🇳🇱 {squad_name}" @@ -4162,14 +4188,14 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): squad_name = f"🇺🇸 {squad_name}" else: squad_name = f"🌐 {squad_name}" - + countries.append({ "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, + "price_kopeks": 0, "is_available": True }) - + await cache.set(cache_key_value, countries, 300) return countries @@ -4182,35 +4208,36 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): await cache.set(cache_key_value, fallback_countries, 60) return fallback_countries + async def _get_countries_info(squad_uuids): countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] + async def handle_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - await handle_device_management(callback, db_user, db) + async def handle_add_country_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): - logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}") logger.info(f"🔍 Callback data: {callback.data}") - + current_state = await state.get_state() logger.info(f"🔍 Текущее состояние: {current_state}") - + country_uuid = callback.data.split('_')[1] data = await state.get_data() logger.info(f"🔍 Данные состояния: {data}") - + selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) allowed_country_ids = {country['uuid'] for country in countries} @@ -4218,14 +4245,14 @@ async def handle_add_country_to_subscription( if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return - + if country_uuid in selected_countries: selected_countries.remove(country_uuid) logger.info(f"🔍 Удалена страна: {country_uuid}") else: selected_countries.append(country_uuid) logger.info(f"🔍 Добавлена страна: {country_uuid}") - + total_price = 0 subscription = db_user.subscription period_hint_days = _get_period_hint_from_subscription(subscription) @@ -4240,8 +4267,8 @@ async def handle_add_country_to_subscription( continue if ( - country['uuid'] in selected_countries - and country['uuid'] not in subscription.connected_squads + country['uuid'] in selected_countries + and country['uuid'] not in subscription.connected_squads ): server_price = country['price_kopeks'] if servers_discount_percent > 0 and server_price > 0: @@ -4259,7 +4286,7 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") - + try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( @@ -4275,9 +4302,10 @@ async def handle_add_country_to_subscription( logger.info(f"✅ Клавиатура обновлена") except Exception as e: logger.error(f"❌ Ошибка обновления клавиатуры: {e}") - + await callback.answer() + async def _should_show_countries_management(user: Optional[User] = None) -> bool: try: promo_group_id = user.promo_group_id if user else None @@ -4315,16 +4343,15 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool async def confirm_add_countries_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): - data = await state.get_data() texts = get_texts(db_user.language) subscription = db_user.subscription - + selected_countries = data.get('countries', []) current_countries = subscription.connected_squads @@ -4339,11 +4366,11 @@ async def confirm_add_countries_to_subscription( new_countries = [c for c in selected_countries if c not in current_countries] removed_countries = [c for c in current_countries if c not in selected_countries] - + if not new_countries and not removed_countries: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + total_price = 0 new_countries_names = [] removed_countries_names = [] @@ -4381,7 +4408,7 @@ async def confirm_add_countries_to_subscription( new_countries_names.append(country['name']) if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) - + if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( @@ -4410,18 +4437,18 @@ async def confirm_add_countries_to_subscription( await state.clear() await callback.answer() return - + try: if new_countries and total_price > 0: success = await subtract_user_balance( db, db_user, total_price, f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + if not success: await callback.answer("❌ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -4429,19 +4456,19 @@ async def confirm_add_countries_to_subscription( amount_kopeks=total_price, description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = "✅ Страны успешно обновлены!\n\n" - + if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: @@ -4452,42 +4479,44 @@ async def confirm_add_countries_to_subscription( f" -{texts.format_price(total_discount_value)})" ) success_text += "\n" - + if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" success_text += "ℹ️ Повторное подключение будет платным\n" - + success_text += f"\n🌍 Активных стран: {len(selected_countries)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") + except Exception as e: logger.error(f"Ошибка обновления стран подписки: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await state.clear() await callback.answer() -async def confirm_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_reset_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): await handle_device_management(callback, db_user, db) + async def handle_happ_download_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) prompt_text = texts.t( @@ -4502,9 +4531,9 @@ async def handle_happ_download_request( async def handle_happ_download_platform_choice( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): platform = callback.data.split('_')[-1] if platform == "pc": @@ -4538,9 +4567,9 @@ async def handle_happ_download_platform_choice( async def handle_happ_download_close( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): try: await callback.message.delete() @@ -4551,9 +4580,9 @@ async def handle_happ_download_close( async def handle_happ_download_back( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) prompt_text = texts.t( @@ -4566,10 +4595,11 @@ async def handle_happ_download_back( await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML") await callback.answer() + async def handle_connect_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription @@ -4728,14 +4758,14 @@ async def handle_connect_subscription( reply_markup=get_device_selection_keyboard(db_user.language), parse_mode="HTML" ) - + await callback.answer() async def claim_discount_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, ): texts = get_texts(db_user.language) @@ -4803,11 +4833,11 @@ async def claim_discount_offer( async def handle_device_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - device_type = callback.data.split('_')[2] + device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) @@ -4833,62 +4863,62 @@ async def handle_device_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + f"\n{subscription_link}\n\n" ) guide_text = ( - texts.t( - "SUBSCRIPTION_DEVICE_GUIDE_TITLE", - "📱 Настройка для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section - + texts.t( - "SUBSCRIPTION_DEVICE_FEATURED_APP", - "📋 Рекомендуемое приложение: {app_name}", - ).format(app_name=featured_app['name']) - + "\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") - + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") - + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") - + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1", - "1. Установите приложение по ссылке выше", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2", - "2. Скопируйте ссылку подписки (нажмите на неё)", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3", - "3. Откройте приложение и вставьте ссылку", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4", - "4. Подключитесь к серверу", - ), - ] - ) + texts.t( + "SUBSCRIPTION_DEVICE_GUIDE_TITLE", + "📱 Настройка для {device_name}", + ).format(device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + link_section + + texts.t( + "SUBSCRIPTION_DEVICE_FEATURED_APP", + "📋 Рекомендуемое приложение: {app_name}", + ).format(app_name=featured_app['name']) + + "\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1", + "1. Установите приложение по ссылке выше", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2", + "2. Скопируйте ссылку подписки (нажмите на неё)", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3", + "3. Откройте приложение и вставьте ссылку", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4", + "4. Подключитесь к серверу", + ), + ] ) - + ) + await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( @@ -4902,16 +4932,16 @@ async def handle_device_guide( async def handle_app_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - device_type = callback.data.split('_')[2] + device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription - + apps = get_apps_for_device(device_type, db_user.language) - + if not apps: await callback.answer( texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"), @@ -4920,14 +4950,14 @@ async def handle_app_selection( return app_text = ( - texts.t( - "SUBSCRIPTION_APPS_TITLE", - "📱 Приложения для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:") + texts.t( + "SUBSCRIPTION_APPS_TITLE", + "📱 Приложения для {device_name}", + ).format(device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:") ) - + await callback.message.edit_text( app_text, reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language), @@ -4937,14 +4967,14 @@ async def handle_app_selection( async def handle_specific_app_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - _, device_type, app_id = callback.data.split('_') + _, device_type, app_id = callback.data.split('_') texts = get_texts(db_user.language) subscription = db_user.subscription - + subscription_link = get_display_subscription_link(subscription) if not subscription_link: @@ -4956,7 +4986,7 @@ async def handle_specific_app_guide( apps = get_apps_for_device(device_type, db_user.language) app = next((a for a in apps if a['id'] == app_id), None) - + if not app: await callback.answer( texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"), @@ -4968,46 +4998,46 @@ async def handle_specific_app_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + f"\n{subscription_link}\n\n" ) guide_text = ( - texts.t( - "SUBSCRIPTION_SPECIFIC_APP_TITLE", - "📱 {app_name} - {device_name}", - ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section - + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") - + f"\n{app['installationStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") - + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") - + f"\n{app['connectAndUseStep']['description'][db_user.language]}" + texts.t( + "SUBSCRIPTION_SPECIFIC_APP_TITLE", + "📱 {app_name} - {device_name}", + ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + link_section + + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + + f"\n{app['installationStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + + f"\n{app['connectAndUseStep']['description'][db_user.language]}" ) if 'additionalAfterAddSubscriptionStep' in app: additional = app['additionalAfterAddSubscriptionStep'] guide_text += ( - "\n\n" - + texts.t( - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE", - "{title}:", - ).format(title=additional['title'][db_user.language]) - + f"\n{additional['description'][db_user.language]}" + "\n\n" + + texts.t( + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE", + "{title}:", + ).format(title=additional['title'][db_user.language]) + + f"\n{additional['description'][db_user.language]}" ) - + await callback.message.edit_text( guide_text, reply_markup=get_specific_app_keyboard( @@ -5020,21 +5050,22 @@ async def handle_specific_app_guide( ) await callback.answer() + async def handle_no_traffic_packages( - callback: types.CallbackQuery, - db_user: User + callback: types.CallbackQuery, + db_user: User ): await callback.answer( "⚠️ В данный момент нет доступных пакетов трафика. " - "Обратитесь в техподдержку для получения информации.", + "Обратитесь в техподдержку для получения информации.", show_alert=True ) async def handle_open_subscription_link( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription @@ -5051,20 +5082,20 @@ async def handle_open_subscription_link( redirect_link = get_happ_cryptolink_redirect_link(subscription_link) happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link) happ_message = ( - texts.t( - "SUBSCRIPTION_HAPP_OPEN_TITLE", - "🔗 Подключение через Happ", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=happ_scheme_link) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_HINT", - "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", - ) + texts.t( + "SUBSCRIPTION_HAPP_OPEN_TITLE", + "🔗 Подключение через Happ", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=happ_scheme_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_HINT", + "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", + ) ) if redirect_link: @@ -5094,43 +5125,44 @@ async def handle_open_subscription_link( return link_text = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n\n" - + f"{subscription_link}\n\n" - + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_LINK_STEP1", - "1. Нажмите на ссылку выше чтобы её скопировать", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP2", - "2. Откройте ваше VPN приложение", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP3", - "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP4", - "4. Вставьте скопированную ссылку", - ), - ] - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HINT", - "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - ) + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n\n" + + f"{subscription_link}\n\n" + + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_LINK_STEP1", + "1. Нажмите на ссылку выше чтобы её скопировать", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP2", + "2. Откройте ваше VPN приложение", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP3", + "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP4", + "4. Вставьте скопированную ссылку", + ), + ] + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HINT", + "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + ) ) await callback.message.edit_text( link_text, reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect") ], [ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") @@ -5145,7 +5177,7 @@ def load_app_config() -> Dict[str, Any]: try: from app.config import settings config_path = settings.get_app_config_path() - + with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: @@ -5154,16 +5186,16 @@ def load_app_config() -> Dict[str, Any]: def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]: - config = load_app_config() - + config = load_app_config()['platforms'] + device_mapping = { 'ios': 'ios', - 'android': 'android', - 'windows': 'pc', - 'mac': 'pc', - 'tv': 'tv' + 'android': 'android', + 'windows': 'windows', + 'mac': 'macos', + 'tv': 'androidTV' } - + config_key = device_mapping.get(device_type, device_type) return config.get(config_key, []) @@ -5185,13 +5217,13 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'mac': 'macOS', 'tv': 'Android TV' } - + return names.get(device_type, device_type) def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str: from app.config import settings - + return subscription_url @@ -5200,7 +5232,7 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( - text="✅ Да, сбросить все устройства", + text="✅ Да, сбросить все устройства", callback_data="confirm_reset_devices" ) ], @@ -5209,19 +5241,21 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa ] ]) -async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, subscription: Subscription): + +async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, + subscription: Subscription): try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") + async def show_device_connection_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) @@ -5251,7 +5285,7 @@ async def show_device_connection_help( 💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств """ - + await callback.message.edit_text( help_text, reply_markup=get_device_management_help_keyboard(db_user.language), @@ -5259,18 +5293,19 @@ async def show_device_connection_help( ) await callback.answer() + async def send_purchase_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - period_days: int, - was_trial_conversion: bool = False + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + period_days: int, + was_trial_conversion: bool = False ): try: from app.database.crud.transaction import get_transaction_by_id - + transaction = await get_transaction_by_id(db, transaction_id) if transaction: notification_service = AdminNotificationService(callback.bot) @@ -5280,18 +5315,19 @@ async def send_purchase_notification( except Exception as e: logger.error(f"Ошибка отправки уведомления о покупке: {e}") + async def send_extension_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - extended_days: int, - old_end_date: datetime + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + extended_days: int, + old_end_date: datetime ): try: from app.database.crud.transaction import get_transaction_by_id - + transaction = await get_transaction_by_id(db, transaction_id) if transaction: notification_service = AdminNotificationService(callback.bot) @@ -5301,24 +5337,25 @@ async def send_extension_notification( except Exception as e: logger.error(f"Ошибка отправки уведомления о продлении: {e}") + async def handle_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -5342,27 +5379,27 @@ async def handle_switch_traffic( ), parse_mode="HTML" ) - + await callback.answer() async def confirm_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price - + new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + current_traffic = subscription.traffic_limit_gb - + if new_traffic_gb == current_traffic: await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True) return - + old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) @@ -5384,9 +5421,9 @@ async def confirm_switch_traffic( ) price_difference_per_month = discounted_new_per_month - discounted_old_per_month discount_savings_per_month = ( - (new_price_per_month - old_price_per_month) - price_difference_per_month + (new_price_per_month - old_price_per_month) - price_difference_per_month ) - + if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining @@ -5417,7 +5454,7 @@ async def confirm_switch_traffic( ) await callback.answer() return - + action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}" cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)" if discount_savings_per_month > 0: @@ -5430,62 +5467,63 @@ async def confirm_switch_traffic( total_price_difference = 0 action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}" cost_text = "Возврат средств не производится" - + confirm_text = f"🔄 Подтверждение переключения трафика\n\n" confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n" confirm_text += f"Действие: {action_text}\n" confirm_text += f"💰 {cost_text}\n\n" confirm_text += "Подтвердить переключение?" - + await callback.message.edit_text( confirm_text, reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language), parse_mode="HTML" ) - + await callback.answer() + async def clear_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + 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, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.utils.pricing_utils import get_remaining_months - + callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) price_difference = int(callback_parts[4]) - + texts = get_texts(db_user.language) subscription = db_user.subscription current_traffic = subscription.traffic_limit_gb - + try: if price_difference > 0: success = await subtract_user_balance( db, db_user, price_difference, f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + months_remaining = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -5494,18 +5532,18 @@ async def execute_switch_traffic( amount_kopeks=price_difference, description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес" ) - + subscription.traffic_limit_gb = new_traffic_gb subscription.updated_at = datetime.utcnow() - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -5514,7 +5552,7 @@ async def execute_switch_traffic( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}") - + if new_traffic_gb > current_traffic: success_text = f"✅ Лимит трафика увеличен!\n\n" success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " @@ -5526,51 +5564,52 @@ async def execute_switch_traffic( success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" success_text += f"ℹ️ Возврат средств не производится" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference/100}₽") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽") + except Exception as e: logger.error(f"Ошибка переключения трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() def get_traffic_switch_keyboard( - current_traffic_gb: int, - language: str = "ru", - subscription_end_date: datetime = None, - discount_percent: int = 0, + current_traffic_gb: int, + language: str = "ru", + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.utils.pricing_utils import get_remaining_months from app.config import settings - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + current_price_per_month = settings.get_traffic_price(current_traffic_gb) discounted_current_per_month, _ = apply_percentage_discount( current_price_per_month, discount_percent, ) - + buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] @@ -5589,14 +5628,14 @@ def get_traffic_switch_keyboard( elif total_price_diff > 0: emoji = "⬆️" action_text = "" - price_text = f" (+{total_price_diff//100}₽{period_text})" + price_text = f" (+{total_price_diff // 100}₽{period_text})" if discount_percent > 0: discount_total = ( - (price_per_month - current_price_per_month) * months_multiplier - - total_price_diff + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff ) if discount_total > 0: - price_text += f" (скидка {discount_percent}%: -{discount_total//100}₽)" + price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)" elif total_price_diff < 0: emoji = "⬇️" action_text = "" @@ -5605,34 +5644,33 @@ def get_traffic_switch_keyboard( emoji = "🔄" action_text = "" price_text = " (бесплатно)" - + if gb == 0: traffic_text = "Безлимит" else: traffic_text = f"{gb} ГБ" - + button_text = f"{emoji} {traffic_text}{action_text}{price_text}" - + buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}") ]) - + buttons.append([ InlineKeyboardButton( text="⬅️ Назад" if language == "ru" else "⬅️ Back", callback_data="subscription_settings" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_confirm_switch_traffic_keyboard( - new_traffic_gb: int, - price_difference: int, - language: str = "ru" + new_traffic_gb: int, + price_difference: int, + language: str = "ru" ) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( @@ -5651,32 +5689,32 @@ def get_confirm_switch_traffic_keyboard( def register_handlers(dp: Dispatcher): update_traffic_prices() - + dp.callback_query.register( show_subscription_info, F.data == "menu_subscription" ) - + dp.callback_query.register( show_trial_offer, F.data == "menu_trial" ) - + dp.callback_query.register( activate_trial, F.data == "trial_activate" ) - + dp.callback_query.register( start_subscription_purchase, F.data.in_(["menu_buy", "subscription_upgrade"]) ) - + dp.callback_query.register( handle_add_countries, F.data == "subscription_add_countries" ) - + dp.callback_query.register( handle_switch_traffic, F.data == "subscription_switch_traffic" @@ -5686,12 +5724,12 @@ def register_handlers(dp: Dispatcher): confirm_switch_traffic, F.data.startswith("switch_traffic_") ) - + dp.callback_query.register( execute_switch_traffic, F.data.startswith("confirm_switch_traffic_") ) - + dp.callback_query.register( handle_change_devices, F.data == "subscription_change_devices" @@ -5701,33 +5739,32 @@ def register_handlers(dp: Dispatcher): confirm_change_devices, F.data.startswith("change_devices_") ) - + dp.callback_query.register( execute_change_devices, F.data.startswith("confirm_change_devices_") ) - + dp.callback_query.register( handle_extend_subscription, F.data == "subscription_extend" ) - + dp.callback_query.register( handle_reset_traffic, F.data == "subscription_reset_traffic" ) - - + dp.callback_query.register( confirm_add_devices, F.data.startswith("add_devices_") ) - + dp.callback_query.register( confirm_extend_subscription, F.data.startswith("extend_period_") ) - + dp.callback_query.register( confirm_reset_traffic, F.data == "confirm_reset_traffic" @@ -5737,36 +5774,36 @@ def register_handlers(dp: Dispatcher): handle_reset_devices, F.data == "subscription_reset_devices" ) - + dp.callback_query.register( confirm_reset_devices, F.data == "confirm_reset_devices" ) - + dp.callback_query.register( select_period, F.data.startswith("period_"), SubscriptionStates.selecting_period ) - + dp.callback_query.register( select_traffic, F.data.startswith("traffic_"), SubscriptionStates.selecting_traffic ) - + dp.callback_query.register( select_devices, F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]), SubscriptionStates.selecting_devices ) - + dp.callback_query.register( devices_continue, F.data == "devices_continue", SubscriptionStates.selecting_devices ) - + dp.callback_query.register( confirm_purchase, F.data == "subscription_confirm", @@ -5787,17 +5824,17 @@ def register_handlers(dp: Dispatcher): clear_saved_cart, F.data == "clear_saved_cart", ) - + dp.callback_query.register( handle_autopay_menu, F.data == "subscription_autopay" ) - + dp.callback_query.register( toggle_autopay, F.data.in_(["autopay_enable", "autopay_disable"]) ) - + dp.callback_query.register( show_autopay_days, F.data == "autopay_set_days" @@ -5807,12 +5844,12 @@ def register_handlers(dp: Dispatcher): handle_subscription_config_back, F.data == "subscription_config_back" ) - + dp.callback_query.register( handle_subscription_cancel, F.data == "subscription_cancel" ) - + dp.callback_query.register( set_autopay_days, F.data.startswith("autopay_days_") @@ -5823,7 +5860,7 @@ def register_handlers(dp: Dispatcher): F.data.startswith("country_"), SubscriptionStates.selecting_countries ) - + dp.callback_query.register( countries_continue, F.data == "countries_continue", @@ -5834,7 +5871,7 @@ def register_handlers(dp: Dispatcher): handle_manage_country, F.data.startswith("country_manage_") ) - + dp.callback_query.register( apply_countries_changes, F.data == "countries_apply" @@ -5875,22 +5912,22 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) - + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") ) - + dp.callback_query.register( handle_app_selection, F.data.startswith("app_list_") ) - + dp.callback_query.register( handle_specific_app_guide, F.data.startswith("app_") ) - + dp.callback_query.register( handle_open_subscription_link, F.data == "open_subscription_link" @@ -5910,17 +5947,17 @@ def register_handlers(dp: Dispatcher): handle_device_management, F.data == "subscription_manage_devices" ) - + dp.callback_query.register( handle_devices_page, F.data.startswith("devices_page_") ) - + dp.callback_query.register( handle_single_device_reset, - F.data.regexp(r"^reset_device_\d+_\d+$") + F.data.regexp(r"^reset_device_\d+_\d+$") ) - + dp.callback_query.register( handle_all_devices_reset_from_management, F.data == "reset_all_devices" From ff661d84ee6f10a8fdc090d127b38bbf2ecd824b Mon Sep 17 00:00:00 2001 From: SantaSpeen Date: Tue, 30 Sep 2025 14:46:55 +0300 Subject: [PATCH 05/24] [+] Update app-config.json: new get_apps_for_device --- app-config.json | 362 +++++++++++++++++++++++++++++------------------- 1 file changed, 216 insertions(+), 146 deletions(-) diff --git a/app-config.json b/app-config.json index 4f5a919f..f53cea84 100644 --- a/app-config.json +++ b/app-config.json @@ -1,163 +1,233 @@ { - "ios": [ - { - "id": "happ", - "name": "Happ", - "isFeatured": true, - "urlScheme": "happ://add/", - "installationStep": { - "buttons": [ - { - "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215", - "buttonText": { - "en": "App Store [EU]", - "ru": "App Store [EU]" - } - }, - { - "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973", - "buttonText": { - "en": "App Store [RU]", - "ru": "App Store [RU]" + "config": {}, + "platforms": { + "ios": [ + { + "id": "happ", + "name": "Happ", + "isFeatured": true, + "urlScheme": "happ://add/", + "installationStep": { + "buttons": [ + { + "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215", + "buttonText": { + "en": "App Store [EU]", + "fa": "App Store [EU]", + "ru": "App Store [EU]", + "zh": "App Store [EU]" + } + }, + { + "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973", + "buttonText": { + "en": "App Store [RU]", + "fa": "App Store [RU]", + "ru": "App Store [RU]", + "zh": "App Store [RU]" + } } + ], + "description": { + "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.", + "fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.", + "ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.", + "zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。" + } + }, + "addSubscriptionStep": { + "description": { + "en": "Click the button below — the app will open and the subscription will be added automatically", + "fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد", + "ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.", + "zh": "点击下方按钮 — 应用将打开并自动添加订阅" + } + }, + "connectAndUseStep": { + "description": { + "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.", + "fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.", + "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.", + "zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。" } - ], - "description": { - "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.", - "ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской" - } - }, - "addSubscriptionStep": { - "description": { - "en": "Click the button below — the app will open and the subscription will be added automatically", - "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически." - } - }, - "connectAndUseStep": { - "description": { - "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.", - "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов." } } - } - ], - "android": [ - { - "id": "happ", - "name": "Happ", - "isFeatured": true, - "urlScheme": "happ://add/", - "installationStep": { - "buttons": [ - { - "buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy", - "buttonText": { - "en": "Google Play", - "ru": "Google Play" - } - }, - { - "buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk", - "buttonText": { - "en": "Download APK", - "ru": "Скачать APK" + ], + "android": [ + { + "id": "happ", + "name": "Happ", + "isFeatured": true, + "urlScheme": "happ://add/", + "installationStep": { + "buttons": [ + { + "buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy", + "buttonText": { + "en": "Google Play", + "fa": "Google Play", + "ru": "Google Play", + "zh": "Google Play" + } + }, + { + "buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk", + "buttonText": { + "en": "Download APK", + "fa": "دانلود APK", + "ru": "Скачать APK", + "zh": "下载 APK" + } } + ], + "description": { + "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.", + "fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.", + "ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.", + "zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。" + } + }, + "addSubscriptionStep": { + "description": { + "en": "Click the button below to add subscription", + "fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید", + "ru": "Нажмите кнопку ниже, чтобы добавить подписку", + "zh": "点击下方按钮添加订阅" + } + }, + "connectAndUseStep": { + "description": { + "en": "Open the app and connect to the server", + "fa": "برنامه را باز کنید و به سرور متصل شوید", + "ru": "Откройте приложение и подключитесь к серверу", + "zh": "打开应用并连接到服务器" } - ], - "description": { - "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.", - "ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает." - } - }, - "addSubscriptionStep": { - "description": { - "en": "Click the button below to add subscription", - "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически." - } - }, - "connectAndUseStep": { - "description": { - "en": "Open the app and connect to the server", - "ru": "Откройте приложение и подключитесь к серверу" } } - } - ], - "pc": [ - { - "id": "happ", - "name": "Happ", - "isFeatured": false, - "urlScheme": "happ://add/", - "installationStep": { - "buttons": [ - { - "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru", - "buttonText": { - "en": "Mac Os", - "ru": "Mac Os" - } - }, - { - "buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe", - "buttonText": { - "en": "Windows", - "ru": "Windows" + ], + "macos": [ + { + "id": "happ", + "name": "Happ", + "isFeatured": true, + "urlScheme": "happ://add/", + "installationStep": { + "buttons": [ + { + "buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215", + "buttonText": { + "en": "App Store [EU]", + "fa": "App Store [EU]", + "ru": "App Store [EU]", + "zh": "App Store [EU]" + } + }, + { + "buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973", + "buttonText": { + "en": "App Store [RU]", + "fa": "App Store [RU]", + "ru": "App Store [RU]", + "zh": "App Store [RU]" + } } + ], + "description": { + "en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.", + "fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.", + "ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.", + "zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。" + } + }, + "addSubscriptionStep": { + "description": { + "en": "Click the button below — the app will open and the subscription will be added automatically", + "fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد", + "ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.", + "zh": "点击下方按钮 — 应用将打开并自动添加订阅" + } + }, + "connectAndUseStep": { + "description": { + "en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.", + "fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.", + "ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.", + "zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。" } - ], - "description": { - "en": "Choose the version for your device, click the button below and install the app.", - "ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение" - } - }, - "addSubscriptionStep": { - "description": { - "en": "Click the button below to add subscription", - "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически." - } - }, - "connectAndUseStep": { - "description": { - "en": "You can select a server", - "ru": "Выберете локацию, включить VPN" } } - } - ], - "tv": [ - { - "id": "vpn4tv", - "name": "VPN4TV", - "isFeatured": true, - "urlScheme": "", - "installationStep": { - "buttons": [ - { - "buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify", - "buttonText": { - "en": "Google Play", - "ru": "Google Play" + ], + "windows": [ + { + "id": "happ", + "name": "Happ", + "isFeatured": false, + "urlScheme": "happ://add/", + "installationStep": { + "buttons": [ + { + "buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe", + "buttonText": { + "en": "Download", + "ru": "Скачать" + } } + ], + "description": { + "en": "Download and install Happ.", + "ru": "Скачайте и установите Happ" + } + }, + "addSubscriptionStep": { + "description": { + "en": "Click the button below to add subscription", + "ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически." + } + }, + "connectAndUseStep": { + "description": { + "en": "You can select a server", + "ru": "Выберете локацию, включить VPN" } - ], - "description": { - "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.", - "ru": "Откройте страницу в Google Play и установите приложение" - } - }, - "addSubscriptionStep": { - "description": { - "en": "Click the button below to add subscription", - "ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал" - } - }, - "connectAndUseStep": { - "description": { - "en": "Open the app and connect to the server", - "ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN" } } - } - ] -} + ], + "androidTV": [ + { + "id": "vpn4tv", + "name": "VPN4TV", + "isFeatured": true, + "urlScheme": "", + "installationStep": { + "buttons": [ + { + "buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify", + "buttonText": { + "en": "Google Play", + "ru": "Google Play" + } + } + ], + "description": { + "en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.", + "ru": "Откройте страницу в Google Play и установите приложение" + } + }, + "addSubscriptionStep": { + "description": { + "en": "Click the button below to add subscription", + "ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал" + } + }, + "connectAndUseStep": { + "description": { + "en": "Open the app and connect to the server", + "ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN" + } + } + } + ], + "linux": [], + "appleTV": [] + } +} \ No newline at end of file From 5efe36878d37b9a5ecb173d6910203e21a48da50 Mon Sep 17 00:00:00 2001 From: SantaSpeen Date: Tue, 30 Sep 2025 17:20:08 +0300 Subject: [PATCH 06/24] [~] Update upstream --- app/handlers/subscription.py | 2093 +++++++++++++++++----------------- 1 file changed, 1057 insertions(+), 1036 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8c2ae0b4..923a07ca 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1,44 +1,40 @@ +import json import logging from datetime import datetime, timedelta -from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession -import json -import os from typing import Dict, List, Any, Tuple, Optional +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession + from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.states import SubscriptionStates +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.database.crud.subscription import ( - get_subscription_by_user_id, create_trial_subscription, - create_paid_subscription, extend_subscription, - add_subscription_traffic, add_subscription_devices, - add_subscription_squad, update_subscription_autopay, - add_subscription_servers + create_trial_subscription, + create_paid_subscription, add_subscription_traffic, add_subscription_devices, + update_subscription_autopay ) +from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance, add_user_balance -from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + Subscription ) -from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, get_countries_keyboard, get_devices_keyboard, get_subscription_confirm_keyboard, get_autopay_keyboard, get_autopay_days_keyboard, get_back_keyboard, - get_extend_subscription_keyboard, get_add_traffic_keyboard, + get_add_traffic_keyboard, get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, get_manage_countries_keyboard, get_device_selection_keyboard, get_connection_guide_keyboard, get_app_selection_keyboard, get_specific_app_keyboard, 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_devices_management_keyboard, get_device_management_help_keyboard, get_happ_cryptolink_keyboard, get_happ_download_platform_keyboard, get_happ_download_link_keyboard, get_happ_download_button_row, @@ -47,15 +43,17 @@ from app.keyboards.inline import ( get_insufficient_balance_keyboard_with_cart ) 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.remnawave_service import RemnaWaveService 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.services.subscription_service import SubscriptionService +from app.states import SubscriptionStates +from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, @@ -64,7 +62,6 @@ from app.utils.pricing_utils import ( format_period_description, apply_percentage_discount, ) -from app.utils.pagination import paginate_list from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -77,9 +74,9 @@ TRAFFIC_PRICES = get_traffic_prices() def _get_addon_discount_percent_for_user( - user: Optional[User], - category: str, - period_days_hint: Optional[int] = None, + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, ) -> int: if user is None: return 0 @@ -98,10 +95,10 @@ def _get_addon_discount_percent_for_user( def _apply_addon_discount( - user: Optional[User], - category: str, - amount: int, - period_days_hint: Optional[int] = None, + user: Optional[User], + category: str, + amount: int, + period_days_hint: Optional[int] = None, ) -> Dict[str, int]: percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) discounted_amount, discount_value = apply_percentage_discount(amount, percent) @@ -125,9 +122,9 @@ def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> def _apply_discount_to_monthly_component( - amount_per_month: int, - percent: int, - months: int, + amount_per_month: int, + percent: int, + months: int, ) -> Dict[str, int]: discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) @@ -142,11 +139,10 @@ def _apply_discount_to_monthly_component( async def _prepare_subscription_summary( - db_user: User, - data: Dict[str, Any], - texts, + db_user: User, + data: Dict[str, Any], + texts, ) -> Tuple[str, Dict[str, Any]]: - summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -234,9 +230,9 @@ async def _prepare_subscription_summary( total_price = base_price + total_traffic_price + total_countries_price + total_devices_price discounted_monthly_additions = ( - traffic_component["discounted_per_month"] - + discounted_servers_price_per_month - + devices_component["discounted_per_month"] + traffic_component["discounted_per_month"] + + discounted_servers_price_per_month + + devices_component["discounted_per_month"] ) is_valid = validate_pricing_calculation( @@ -351,9 +347,9 @@ async def _prepare_subscription_summary( def _build_promo_group_discount_text( - db_user: User, - periods: Optional[List[int]] = None, - texts=None, + db_user: User, + periods: Optional[List[int]] = None, + texts=None, ) -> str: promo_group = getattr(db_user, "promo_group", None) @@ -446,15 +442,15 @@ def _build_subscription_period_prompt(db_user: User, texts) -> str: async def show_subscription_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): await db.refresh(db_user) - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription: await callback.message.edit_text( texts.SUBSCRIPTION_NONE, @@ -462,17 +458,17 @@ async def show_subscription_info( ) await callback.answer() return - + from app.database.crud.subscription import check_and_update_subscription_status subscription = await check_and_update_subscription_status(db, subscription) - + subscription_service = SubscriptionService() await subscription_service.sync_subscription_usage(db, subscription) - + await db.refresh(subscription) - + current_time = datetime.utcnow() - + if subscription.status == "expired" or subscription.end_date <= current_time: actual_status = "expired" status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла") @@ -536,7 +532,7 @@ async def show_subscription_info( "SUBSCRIPTION_TRAFFIC_LIMITED", "{used} / {limit} ГБ", ).format(used=used_traffic, limit=subscription.traffic_limit_gb) - + devices_used_str = "—" devices_list = [] devices_count = 0 @@ -545,10 +541,10 @@ async def show_subscription_info( if db_user.remnawave_uuid: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] devices_count = devices_info.get('total', 0) @@ -557,7 +553,7 @@ async def show_subscription_info( logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}") else: logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка получения устройств для отображения: {e}") devices_used_str = await get_current_devices_count(db_user) @@ -611,14 +607,14 @@ async def show_subscription_info( device_info = device_info[:32] + "..." message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if ( - subscription_link - and actual_status in ["trial_active", "paid_active"] - and not hide_subscription_link + subscription_link + and actual_status in ["trial_active", "paid_active"] + and not hide_subscription_link ): message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECT_LINK_SECTION", @@ -628,7 +624,7 @@ async def show_subscription_info( "SUBSCRIPTION_CONNECT_LINK_PROMPT", "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", ) - + await callback.message.edit_text( message, reply_markup=get_subscription_keyboard( @@ -641,43 +637,45 @@ async def show_subscription_info( ) await callback.answer() + async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: return {"count": 0, "devices": []} - + from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) - + return { "count": total_devices, - "devices": devices_list[:5] + "devices": devices_list[:5] } else: return {"count": 0, "devices": []} - + except Exception as e: logger.error(f"Ошибка получения детальной информации об устройствах: {e}") return {"count": 0, "devices": []} + async def get_servers_display_names(squad_uuids: List[str]) -> str: if not squad_uuids: return "Нет серверов" - + try: from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_server_squad_by_uuid - + server_names = [] - + async with AsyncSessionLocal() as db: for uuid in squad_uuids: server = await get_server_squad_by_uuid(db, uuid) @@ -686,7 +684,7 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}") else: logger.warning(f"Сервер с UUID {uuid} не найден в БД") - + if not server_names: countries = await _get_available_countries() for uuid in squad_uuids: @@ -695,42 +693,43 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: server_names.append(country['name']) logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}") break - + if not server_names: if len(squad_uuids) == 1: return "🎯 Тестовый сервер" return f"{len(squad_uuids)} стран" - + if len(server_names) > 6: displayed = ", ".join(server_names[:6]) remaining = len(server_names) - 6 return f"{displayed} и ещё {remaining}" else: return ", ".join(server_names) - + except Exception as e: logger.error(f"Ошибка получения названий серверов: {e}") if len(squad_uuids) == 1: return "🎯 Тестовый сервер" return f"{len(squad_uuids)} стран" + async def get_current_devices_count(db_user: User) -> str: try: if not db_user.remnawave_uuid: return "—" - + from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: total_devices = response['response'].get('total', 0) return str(total_devices) else: return "—" - + except Exception as e: logger.error(f"Ошибка получения количества устройств: {e}") return "—" @@ -740,12 +739,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: try: if subscription.is_trial: return 0 - + from app.config import settings from app.services.subscription_service import SubscriptionService - + subscription_service = SubscriptionService() - + base_cost_original = PERIOD_PRICES.get(30, 0) try: owner = subscription.user @@ -780,43 +779,43 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: db, promo_group_id=promo_group_id, ) - + traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - + total_cost = base_cost + servers_cost + traffic_cost + devices_cost - + logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:") - base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽" + base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽" if period_discount_percent > 0: discount_value = base_cost_original * period_discount_percent // 100 base_log += ( - f" → {base_cost/100}₽" - f" (скидка {period_discount_percent}%: -{discount_value/100}₽)" + f" → {base_cost / 100}₽" + f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)" ) logger.info(base_log) if servers_cost > 0: - logger.info(f" 🌍 Серверы: {servers_cost/100}₽") + logger.info(f" 🌍 Серверы: {servers_cost / 100}₽") if traffic_cost > 0: - logger.info(f" 📊 Трафик: {traffic_cost/100}₽") + logger.info(f" 📊 Трафик: {traffic_cost / 100}₽") if devices_cost > 0: - logger.info(f" 📱 Устройства: {devices_cost/100}₽") - logger.info(f" 💎 ИТОГО: {total_cost/100}₽") - + logger.info(f" 📱 Устройства: {devices_cost / 100}₽") + logger.info(f" 💎 ИТОГО: {total_cost / 100}₽") + return total_cost - + except Exception as e: logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}") return 0 async def show_trial_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) - + if db_user.subscription or db_user.has_had_paid_subscription: await callback.message.edit_text( texts.TRIAL_ALREADY_USED, @@ -824,11 +823,11 @@ async def show_trial_offer( ) await callback.answer() return - - trial_server_name = "🎯 Тестовый сервер" + + trial_server_name = "🎯 Тестовый сервер" try: from app.database.crud.server_squad import get_server_squad_by_uuid - + if settings.TRIAL_SQUAD_UUID: trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID) if trial_server: @@ -837,17 +836,17 @@ async def show_trial_offer( logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД") else: logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации") - + except Exception as e: logger.error(f"Ошибка получения триального сервера: {e}") - + trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, devices=settings.TRIAL_DEVICE_LIMIT, server_name=trial_server_name ) - + await callback.message.edit_text( trial_text, reply_markup=get_trial_keyboard(db_user.language) @@ -856,14 +855,14 @@ async def show_trial_offer( async def activate_trial( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.services.admin_notification_service import AdminNotificationService - + texts = get_texts(db_user.language) - + if db_user.subscription or db_user.has_had_paid_subscription: await callback.message.edit_text( texts.TRIAL_ALREADY_USED, @@ -871,54 +870,54 @@ async def activate_trial( ) await callback.answer() return - + try: subscription = await create_trial_subscription(db, db_user.id) - + await db.refresh(db_user) - + subscription_service = SubscriptionService() remnawave_user = await subscription_service.create_remnawave_user( db, subscription ) - + await db.refresh(db_user) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) elif hide_subscription_link: trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.TRIAL_ACTIVATED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) else: subscription_import_link = texts.t( @@ -942,7 +941,8 @@ async def activate_trial( web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": if not settings.MINIAPP_CUSTOM_URL: @@ -962,7 +962,8 @@ async def activate_trial( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "link": rows = [ @@ -999,10 +1000,12 @@ async def activate_trial( connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) - + await callback.message.edit_text( trial_success_text, reply_markup=connect_keyboard, @@ -1013,23 +1016,23 @@ async def activate_trial( f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка активации триала: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def start_subscription_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): texts = get_texts(db_user.language) @@ -1050,27 +1053,26 @@ async def start_subscription_purchase( 'devices': initial_devices, 'total_price': 0 } - + if settings.is_traffic_fixed(): initial_data['traffic_gb'] = settings.get_fixed_traffic_limit() else: initial_data['traffic_gb'] = None - + await state.set_data(initial_data) 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 + 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, @@ -1078,7 +1080,7 @@ async def save_cart_and_redirect_to_topup( 'missing_amount': missing_amount, 'return_to_cart': True }) - + await callback.message.edit_text( f"💰 Недостаточно средств для оформления подписки\n\n" f"Требуется: {texts.format_price(missing_amount)}\n" @@ -1093,21 +1095,22 @@ async def save_cart_and_redirect_to_topup( parse_mode="HTML" ) + async def return_to_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + 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( @@ -1121,23 +1124,22 @@ async def return_to_saved_cart( ) ) return - - + countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] - + months_in_period = calculate_months_from_days(data['period_days']) period_display = format_period_description(data['period_days'], db_user.language) - + 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" @@ -1147,26 +1149,27 @@ async def return_to_saved_cart( 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, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): if not await _should_show_countries_management(db_user): await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1183,28 +1186,28 @@ async def handle_add_countries( "servers", period_hint_days, ) - + current_countries_names = [] for country in countries: if country['uuid'] in current_countries: current_countries_names.append(country['name']) - + text = "🌍 Управление странами подписки\n\n" text += f"📋 Текущие страны ({len(current_countries)}):\n" if current_countries_names: text += "\n".join(f"• {name}" for name in current_countries_names) else: text += "Нет подключенных стран" - + text += "\n\n💡 Инструкция:\n" text += "✅ - страна подключена\n" text += "➕ - будет добавлена (платно)\n" text += "➖ - будет отключена (бесплатно)\n" text += "⚪ - не выбрана\n\n" text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" - + await state.update_data(countries=current_countries.copy()) - + await callback.message.edit_text( text, reply_markup=get_manage_countries_keyboard( @@ -1217,20 +1220,21 @@ async def handle_add_countries( ), parse_mode="HTML" ) - + await callback.answer() + async def get_countries_price_by_uuids_fallback( - country_uuids: List[str], - db: AsyncSession, - promo_group_id: Optional[int] = None, + country_uuids: List[str], + db: AsyncSession, + promo_group_id: Optional[int] = None, ) -> Tuple[int, List[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid - + total_price = 0 prices_list = [] - + for country_uuid in country_uuids: try: server = await get_server_squad_by_uuid(db, country_uuid) @@ -1251,24 +1255,25 @@ async def get_countries_price_by_uuids_fallback( default_price = 0 total_price += default_price prices_list.append(default_price) - + return total_price, prices_list - + except Exception as e: logger.error(f"Ошибка fallback функции: {e}") default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices + async def handle_manage_country( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): logger.info(f"🔍 Управление страной: {callback.data}") - - country_uuid = callback.data.split('_')[2] - + + country_uuid = callback.data.split('_')[2] + subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer("⚠ Только для платных подписок", show_alert=True) @@ -1314,21 +1319,21 @@ async def handle_manage_country( ) ) logger.info(f"✅ Клавиатура обновлена") - + except Exception as e: logger.error(f"⚠ Ошибка обновления клавиатуры: {e}") - + await callback.answer() + async def apply_countries_changes( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): - logger.info(f"🔧 Применение изменений стран") - + data = await state.get_data() texts = get_texts(db_user.language) @@ -1339,7 +1344,7 @@ async def apply_countries_changes( else None ) subscription = db_user.subscription - + selected_countries = data.get('countries', []) current_countries = subscription.connected_squads @@ -1418,7 +1423,7 @@ async def apply_countries_changes( total_cost / 100, total_discount / 100, ) - + if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" @@ -1448,17 +1453,17 @@ async def apply_countries_changes( ) await callback.answer() return - + try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, + db, db_user, total_cost, f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -1466,28 +1471,29 @@ async def apply_countries_changes( amount_kopeks=total_cost, description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес" ) - + if added: from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers from app.database.crud.subscription import add_subscription_servers - + added_server_ids = await get_server_ids_by_uuids(db, added) - + if added_server_ids: await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) await add_user_to_servers(db, added_server_ids) - - logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") - + + logger.info( + f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") + subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -1496,9 +1502,9 @@ async def apply_countries_changes( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}") - + success_text = "✅ Страны успешно обновлены!\n\n" - + if added_names: success_text += f"➕ Добавлены страны:\n" success_text += "\n".join(f"• {name}" for name in added_names) @@ -1510,55 +1516,56 @@ async def apply_countries_changes( f" -{texts.format_price(total_discount)})" ) success_text += "\n" - + if removed_names: success_text += f"\n➖ Отключены страны:\n" success_text += "\n".join(f"• {name}" for name in removed_names) success_text += "\nℹ️ Повторное подключение будет платным\n" - + success_text += f"\n🌐 Активных стран: {len(selected_countries)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) - + await state.clear() - logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽") - + logger.info( + f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽") + except Exception as e: logger.error(f"⚠️ Ошибка применения изменений: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def handle_add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return - + if subscription.traffic_limit_gb == 0: await callback.answer("⚠ У вас уже безлимитный трафик", show_alert=True) return - + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -1578,22 +1585,22 @@ async def handle_add_traffic( ), parse_mode="HTML" ) - + await callback.answer() - + async def handle_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) @@ -1618,43 +1625,43 @@ async def handle_change_devices( ), parse_mode="HTML" ) - + await callback.answer() + async def confirm_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + current_devices = subscription.device_limit - + if new_devices_count == current_devices: await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True) return - + if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})", show_alert=True ) return - + devices_difference = new_devices_count - current_devices - - if devices_difference > 0: + + if devices_difference > 0: additional_devices = devices_difference - + if current_devices < settings.DEFAULT_DEVICE_LIMIT: free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices chargeable_devices = max(0, additional_devices - free_devices) else: chargeable_devices = additional_devices - + devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None @@ -1672,7 +1679,7 @@ async def confirm_change_devices( subscription.end_date, ) total_discount = discount_per_month * charged_months - + if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -1701,7 +1708,7 @@ async def confirm_change_devices( ) await callback.answer() return - + action_text = f"увеличить до {new_devices_count}" if price > 0: cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" @@ -1712,53 +1719,52 @@ async def confirm_change_devices( ) else: cost_text = "Бесплатно" - - else: + + else: price = 0 action_text = f"уменьшить до {new_devices_count}" cost_text = "Возврат средств не производится" - + confirm_text = f"📱 Подтверждение изменения\n\n" confirm_text += f"Текущее количество: {current_devices} устройств\n" confirm_text += f"Новое количество: {new_devices_count} устройств\n\n" confirm_text += f"Действие: {action_text}\n" confirm_text += f"💰 {cost_text}\n\n" confirm_text += "Подтвердить изменение?" - + await callback.message.edit_text( confirm_text, reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language), parse_mode="HTML" ) - + await callback.answer() async def execute_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) - + texts = get_texts(db_user.language) subscription = db_user.subscription current_devices = subscription.device_limit - + try: if price > 0: success = await subtract_user_balance( db, db_user, price, f"Изменение количества устройств с {current_devices} до {new_devices_count}" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + charged_months = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -1767,18 +1773,18 @@ async def execute_change_devices( amount_kopeks=price, description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес" ) - + subscription.device_limit = new_devices_count subscription.updated_at = datetime.utcnow() - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -1787,7 +1793,7 @@ async def execute_change_devices( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}") - + if new_devices_count > current_devices: success_text = f"✅ Количество устройств увеличено!\n\n" success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" @@ -1797,52 +1803,53 @@ async def execute_change_devices( success_text = f"✅ Количество устройств уменьшено!\n\n" success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" success_text += f"ℹ️ Возврат средств не производится" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price/100}₽") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽") + except Exception as e: logger.error(f"Ошибка изменения количества устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() + async def handle_device_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + if not db_user.remnawave_uuid: await callback.answer("❌ UUID пользователя не найден", show_alert=True) return - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_info = response['response'] total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) - + if total_devices == 0: await callback.message.edit_text( "ℹ️ У вас нет подключенных устройств", @@ -1850,56 +1857,54 @@ async def handle_device_management( ) await callback.answer() return - + await show_devices_page(callback, db_user, devices_list, page=1) else: await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) - + except Exception as e: logger.error(f"Ошибка получения списка устройств: {e}") await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) - + await callback.answer() async def show_devices_page( - callback: types.CallbackQuery, - db_user: User, - devices_list: List[dict], - page: int = 1 + callback: types.CallbackQuery, + db_user: User, + devices_list: List[dict], + page: int = 1 ): - - texts = get_texts(db_user.language) devices_per_page = 5 - + pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - + devices_text = f"🔄 Управление устройствами\n\n" devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n" devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n" - + if pagination.items: devices_text += "Подключенные устройства:\n" for i, device in enumerate(pagination.items, 1): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" - + if len(device_info) > 35: device_info = device_info[:32] + "..." - + devices_text += f"• {device_info}\n" - + devices_text += "\n💡 Действия:\n" devices_text += "• Выберите устройство для сброса\n" devices_text += "• Или сбросьте все устройства сразу" - + await callback.message.edit_text( devices_text, reply_markup=get_devices_management_keyboard( - pagination.items, - pagination, + pagination.items, + pagination, db_user.language ), parse_mode="HTML" @@ -1907,103 +1912,102 @@ async def show_devices_page( async def handle_devices_page( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - page = int(callback.data.split('_')[2]) - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_list = response['response'].get('devices', []) await show_devices_page(callback, db_user, devices_list, page=page) else: await callback.answer("❌ Ошибка получения устройств", show_alert=True) - + except Exception as e: logger.error(f"Ошибка перехода на страницу устройств: {e}") await callback.answer("❌ Ошибка загрузки страницы", show_alert=True) async def handle_single_device_reset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - try: callback_parts = callback.data.split('_') if len(callback_parts) < 4: logger.error(f"Некорректный формат callback_data: {callback.data}") await callback.answer("❌ Ошибка: некорректный запрос", show_alert=True) return - + device_index = int(callback_parts[2]) page = int(callback_parts[3]) - + logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}") - + except (ValueError, IndexError) as e: logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}") await callback.answer("❌ Ошибка обработки запроса", show_alert=True) return - + texts = get_texts(db_user.language) - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if response and 'response' in response: devices_list = response['response'].get('devices', []) - + devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - + if device_index < len(pagination.items): device = pagination.items[device_index] device_hwid = device.get('hwid') - + if device_hwid: delete_data = { "userUuid": db_user.remnawave_uuid, "hwid": device_hwid } - + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) - + platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" - + await callback.answer(f"✅ Устройство {device_info} успешно сброшено!", show_alert=True) - + updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if updated_response and 'response' in updated_response: updated_devices = updated_response['response'].get('devices', []) - + if updated_devices: - updated_pagination = paginate_list(updated_devices, page=page, per_page=devices_per_page) + updated_pagination = paginate_list(updated_devices, page=page, + per_page=devices_per_page) if not updated_pagination.items and page > 1: page = page - 1 - + await show_devices_page(callback, db_user, updated_devices, page=page) else: await callback.message.edit_text( "ℹ️ Все устройства сброшены", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}") else: await callback.answer("❌ Не удалось получить ID устройства", show_alert=True) @@ -2011,46 +2015,45 @@ async def handle_single_device_reset( await callback.answer("❌ Устройство не найдено", show_alert=True) else: await callback.answer("❌ Ошибка получения устройств", show_alert=True) - + except Exception as e: logger.error(f"Ошибка сброса устройства: {e}") await callback.answer("❌ Ошибка сброса устройства", show_alert=True) async def handle_all_devices_reset_from_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - texts = get_texts(db_user.language) - + if not db_user.remnawave_uuid: await callback.answer("❌ UUID пользователя не найден", show_alert=True) return - + try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() - + async with service.get_api_client() as api: devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') - + if not devices_response or 'response' not in devices_response: await callback.answer("❌ Ошибка получения списка устройств", show_alert=True) return - + devices_list = devices_response['response'].get('devices', []) - + if not devices_list: await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True) return - + logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса") - + success_count = 0 failed_count = 0 - + for device in devices_list: device_hwid = device.get('hwid') if device_hwid: @@ -2059,18 +2062,18 @@ async def handle_all_devices_reset_from_management( "userUuid": db_user.remnawave_uuid, "hwid": device_hwid } - + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) success_count += 1 logger.info(f"✅ Устройство {device_hwid} удалено") - + except Exception as device_error: failed_count += 1 logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}") else: failed_count += 1 logger.warning(f"⚠️ У устройства нет HWID: {device}") - + if success_count > 0: if failed_count == 0: await callback.message.edit_text( @@ -2091,7 +2094,8 @@ async def handle_all_devices_reset_from_management( reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) - logger.warning(f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") + logger.warning( + f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") else: await callback.message.edit_text( f"❌ Не удалось сбросить устройства\n\n" @@ -2101,38 +2105,38 @@ async def handle_all_devices_reset_from_management( parse_mode="HTML" ) logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}") - + except Exception as e: logger.error(f"Ошибка сброса всех устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def handle_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True) return - + subscription_service = SubscriptionService() - + available_periods = settings.get_available_renewal_periods() renewal_prices = {} - + for days in available_periods: try: months_in_period = calculate_months_from_days(days) - + from app.config import PERIOD_PRICES from app.utils.pricing_utils import apply_percentage_discount @@ -2142,7 +2146,7 @@ async def handle_extend_subscription( base_price_original, period_discount_percent, ) - + servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db, @@ -2174,15 +2178,15 @@ async def handle_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price renewal_prices[days] = price - + except Exception as e: logger.error(f"Ошибка расчета цены для периода {days}: {e}") continue - + if not renewal_prices: await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True) return - + prices_text = "" for days in available_periods: @@ -2217,38 +2221,38 @@ async def handle_extend_subscription( reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), parse_mode="HTML" ) - + await callback.answer() async def handle_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True) return - + if subscription.traffic_limit_gb == 0: await callback.answer("⌛ У вас безлимитный трафик", show_alert=True) return - + reset_price = PERIOD_PRICES[30] - + if db_user.balance_kopeks < reset_price: await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True) return - + await callback.message.edit_text( f"🔄 Сброс трафика\n\n" f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n" @@ -2257,7 +2261,7 @@ async def handle_reset_traffic( "После сброса счетчик использованного трафика станет равным 0.", reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language) ) - + await callback.answer() @@ -2268,19 +2272,18 @@ def update_traffic_prices(): async def confirm_add_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription resume_callback = None - + new_total_devices = subscription.device_limit + devices_count - + if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT: await callback.answer( f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). " @@ -2288,7 +2291,7 @@ async def confirm_add_devices( show_alert=True ) return - + devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE months_hint = get_remaining_months(subscription.end_date) period_hint_days = months_hint * 30 if months_hint > 0 else None @@ -2315,7 +2318,7 @@ async def confirm_add_devices( price / 100, total_discount / 100, ) - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = f"{texts.format_price(price)} (за {charged_months} мес)" @@ -2345,22 +2348,22 @@ async def confirm_add_devices( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, price, f"Добавление {devices_count} устройств на {charged_months} мес" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + await add_subscription_devices(db, subscription, devices_count) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -2368,11 +2371,10 @@ async def confirm_add_devices( amount_kopeks=price, description=f"Добавление {devices_count} устройств на {charged_months} мес" ) - - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = ( "✅ Устройства успешно добавлены!\n\n" f"📱 Добавлено: {devices_count} устройств\n" @@ -2389,23 +2391,23 @@ async def confirm_add_devices( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price/100}₽") - + + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽") + except Exception as e: logger.error(f"Ошибка добавления устройств: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() async def confirm_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.services.admin_notification_service import AdminNotificationService @@ -2453,7 +2455,7 @@ async def confirm_extend_subscription( server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period discounted_servers_price_per_month = servers_price_per_month - ( - servers_price_per_month * servers_discount_percent // 100 + servers_price_per_month * servers_discount_percent // 100 ) additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) @@ -2478,9 +2480,9 @@ async def confirm_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price monthly_additions = ( - discounted_servers_price_per_month - + discounted_devices_price_per_month - + discounted_traffic_price_per_month + discounted_servers_price_per_month + + discounted_devices_price_per_month + + discounted_traffic_price_per_month ) is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price) @@ -2490,47 +2492,47 @@ async def confirm_extend_subscription( return logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - base_log = f" 📅 Период {days} дней: {base_price_original/100}₽" + base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽" if base_discount_total > 0: base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" + f" → {base_price / 100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)" ) logger.info(base_log) if total_servers_price > 0: logger.info( - f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_servers_price/100}₽" + f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_servers_price / 100}₽" + ( f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount/100}₽)" + f" -{total_servers_discount / 100}₽)" if total_servers_discount > 0 else "" ) ) if total_devices_price > 0: logger.info( - f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_devices_price/100}₽" + f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_devices_price / 100}₽" + ( f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_per_month * months_in_period/100}₽)" + f" -{devices_discount_per_month * months_in_period / 100}₽)" if devices_discount_percent > 0 and devices_discount_per_month > 0 else "" ) ) if total_traffic_price > 0: logger.info( - f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_traffic_price/100}₽" + f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_traffic_price / 100}₽" + ( f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_per_month * months_in_period/100}₽)" + f" -{traffic_discount_per_month * months_in_period / 100}₽)" if traffic_discount_percent > 0 and traffic_discount_per_month > 0 else "" ) ) - logger.info(f" 💎 ИТОГО: {price/100}₽") + logger.info(f" 💎 ИТОГО: {price / 100}₽") except Exception as e: logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") @@ -2665,7 +2667,7 @@ async def confirm_extend_subscription( reply_markup=get_back_keyboard(db_user.language) ) - logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽") + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽") except Exception as e: logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") @@ -2681,19 +2683,19 @@ async def confirm_extend_subscription( async def confirm_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + reset_price = PERIOD_PRICES[30] if db_user.balance_kopeks < reset_price: @@ -2723,29 +2725,29 @@ async def confirm_reset_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, reset_price, "Сброс трафика" ) - + if not success: await callback.answer("⌛ Ошибка списания средств", show_alert=True) return - + subscription.traffic_used_gb = 0.0 subscription.updated_at = datetime.utcnow() await db.commit() - + subscription_service = SubscriptionService() remnawave_service = RemnaWaveService() - + user = db_user if user.remnawave_uuid: async with remnawave_service.get_api_client() as api: await api.reset_user_traffic(user.remnawave_uuid) - + await create_transaction( db=db, user_id=db_user.id, @@ -2753,56 +2755,55 @@ async def confirm_reset_traffic( amount_kopeks=reset_price, description="Сброс трафика" ) - + await db.refresh(db_user) await db.refresh(subscription) - + await callback.message.edit_text( f"✅ Трафик успешно сброшен!\n\n" f"🔄 Использованный трафик обнулен\n" f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик") - + except Exception as e: logger.error(f"Ошибка сброса трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() - async def select_period( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): period_days = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) - + data = await state.get_data() data['period_days'] = period_days data['total_price'] = PERIOD_PRICES[period_days] - + if settings.is_traffic_fixed(): fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) data['total_price'] += fixed_traffic_price data['traffic_gb'] = settings.get_fixed_traffic_limit() - + await state.set_data(data) - + if settings.is_traffic_selectable(): available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']] - + if not available_packages: await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True) return - + await callback.message.edit_text( texts.SELECT_TRAFFIC, reply_markup=get_traffic_packages_keyboard(db_user.language) @@ -2821,7 +2822,7 @@ async def select_period( available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( @@ -2829,67 +2830,69 @@ async def select_period( reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) - + await callback.answer() + async def refresh_traffic_config(): try: from app.config import refresh_traffic_prices refresh_traffic_prices() - + packages = settings.get_traffic_packages() enabled_count = sum(1 for pkg in packages if pkg['enabled']) - + logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов") for pkg in packages: if pkg['enabled']: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - logger.info(f" 📦 {gb_text}: {pkg['price']/100}₽") - + logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽") + return True - + except Exception as e: logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}") return False + async def get_traffic_packages_info() -> str: try: packages = settings.get_traffic_packages() - + info_lines = ["📦 Настроенные пакеты трафика:"] - + enabled_packages = [pkg for pkg in packages if pkg['enabled']] disabled_packages = [pkg for pkg in packages if not pkg['enabled']] - + if enabled_packages: info_lines.append("\n✅ Активные:") for pkg in enabled_packages: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price']//100}₽") - + info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + if disabled_packages: info_lines.append("\n❌ Отключенные:") for pkg in disabled_packages: gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price']//100}₽") - + info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + info_lines.append(f"\n📊 Всего пакетов: {len(packages)}") info_lines.append(f"🟢 Активных: {len(enabled_packages)}") info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}") - + return "\n".join(info_lines) - + except Exception as e: return f"⚠️ Ошибка получения информации: {e}" + async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): - devices_used = await get_current_devices_count(db_user) countries_info = await _get_countries_info(subscription.connected_squads) countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" - + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." - + if subscription.is_trial: status_text = "🎁 Тестовая" type_text = "Триал" @@ -2899,7 +2902,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess else: status_text = "⌛ Истекла" type_text = "Платная подписка" - + if subscription.traffic_limit_gb == 0: if settings.is_traffic_fixed(): traffic_text = "∞ Безлимитный" @@ -2910,9 +2913,9 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess traffic_text = f"{subscription.traffic_limit_gb} ГБ" else: traffic_text = f"{subscription.traffic_limit_gb} ГБ" - + subscription_cost = await get_subscription_cost(subscription, db) - + info_text = texts.SUBSCRIPTION_INFO.format( status=status_text, type=type_text, @@ -2925,23 +2928,24 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess devices_limit=subscription.device_limit, autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен" ) - + if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" - + if ( - subscription_url - and subscription_url != "Генерируется..." - and not settings.should_hide_subscription_link() + subscription_url + and subscription_url != "Генерируется..." + and not settings.should_hide_subscription_link() ): info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" return info_text + def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: if is_fixed_mode is None: is_fixed_mode = settings.is_traffic_fixed() - + if traffic_gb == 0: if is_fixed_mode: return "Безлимитный" @@ -2953,22 +2957,23 @@ def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: else: return f"{traffic_gb} ГБ" + async def select_traffic( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): traffic_gb = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) - + data = await state.get_data() data['traffic_gb'] = traffic_gb - + traffic_price = settings.get_traffic_price(traffic_gb) data['total_price'] += traffic_price - + await state.set_data(data) - + if await _should_show_countries_management(db_user): countries = await _get_available_countries(db_user.promo_group_id) await callback.message.edit_text( @@ -2981,7 +2986,7 @@ async def select_traffic( available_countries = [c for c in countries if c.get('is_available', True)] data['countries'] = [available_countries[0]['uuid']] if available_countries else [] await state.set_data(data) - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( @@ -2989,32 +2994,32 @@ async def select_traffic( reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) - + await callback.answer() async def select_country( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): country_uuid = callback.data.split('_')[1] data = await state.get_data() - + selected_countries = data.get('countries', []) if country_uuid in selected_countries: selected_countries.remove(country_uuid) else: selected_countries.append(country_uuid) - + countries = await _get_available_countries(db_user.promo_group_id) allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return - + period_base_price = PERIOD_PRICES[data['period_days']] discounted_base_price, _ = apply_percentage_discount( @@ -3023,7 +3028,7 @@ async def select_country( ) base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) - + try: subscription_service = SubscriptionService() countries_price, _ = await subscription_service.get_countries_price_by_uuids( @@ -3038,11 +3043,11 @@ async def select_country( db, promo_group_id=db_user.promo_group_id, ) - + data['countries'] = selected_countries data['total_price'] = base_price + countries_price await state.set_data(data) - + await callback.message.edit_reply_markup( reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language) ) @@ -3050,73 +3055,73 @@ async def select_country( async def countries_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): - data = await state.get_data() texts = get_texts(db_user.language) - + if not data.get('countries'): await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True) return - + selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) - + await state.set_state(SubscriptionStates.selecting_devices) await callback.answer() async def select_devices( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User + callback: types.CallbackQuery, + state: FSMContext, + db_user: User ): if not callback.data.startswith("devices_") or callback.data == "devices_continue": await callback.answer("❌ Некорректный запрос", show_alert=True) return - + try: devices = int(callback.data.split('_')[1]) except (ValueError, IndexError): await callback.answer("❌ Некорректное количество устройств", show_alert=True) return - + data = await state.get_data() - + base_price = ( - PERIOD_PRICES[data['period_days']] + - settings.get_traffic_price(data['traffic_gb']) + PERIOD_PRICES[data['period_days']] + + settings.get_traffic_price(data['traffic_gb']) ) - + countries = await _get_available_countries(db_user.promo_group_id) countries_price = sum( - c['price_kopeks'] for c in countries + c['price_kopeks'] for c in countries if c['uuid'] in data['countries'] ) - + devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - + data['devices'] = devices data['total_price'] = base_price + countries_price + devices_price await state.set_data(data) - + await callback.message.edit_reply_markup( reply_markup=get_devices_keyboard(devices, db_user.language) ) await callback.answer() + async def devices_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): if not callback.data == "devices_continue": await callback.answer("⚠️ Некорректный запрос", show_alert=True) @@ -3146,13 +3151,13 @@ async def devices_continue( async def confirm_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): from app.services.admin_notification_service import AdminNotificationService - + data = await state.get_data() texts = get_texts(db_user.language) @@ -3164,7 +3169,7 @@ async def confirm_purchase( ) countries = await _get_available_countries(db_user.promo_group_id) - + months_in_period = data.get( 'months_in_period', calculate_months_from_days(data['period_days']) ) @@ -3318,55 +3323,55 @@ async def confirm_purchase( months_in_period, final_price, ) - + if not is_valid: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - + logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") - base_log = f" Период: {base_price_original/100}₽" + base_log = f" Период: {base_price_original / 100}₽" if base_discount_total and base_discount_total > 0: base_log += ( - f" → {base_price/100}₽" - f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)" + f" → {base_price / 100}₽" + f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)" ) logger.info(base_log) if total_traffic_price > 0: message = ( - f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_traffic_price/100}₽" + f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_traffic_price / 100}₽" ) if traffic_discount_total > 0: message += ( f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_total/100}₽)" + f" -{traffic_discount_total / 100}₽)" ) logger.info(message) if total_servers_price > 0: message = ( - f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_servers_price/100}₽" + f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_servers_price / 100}₽" ) if total_servers_discount > 0: message += ( f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount/100}₽)" + f" -{total_servers_discount / 100}₽)" ) logger.info(message) if total_devices_price > 0: message = ( - f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" - f" = {total_devices_price/100}₽" + f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_devices_price / 100}₽" ) if devices_discount_total > 0: message += ( f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_total/100}₽)" + f" -{devices_discount_total / 100}₽)" ) logger.info(message) - logger.info(f" ИТОГО: {final_price/100}₽") - + logger.info(f" ИТОГО: {final_price / 100}₽") + if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( @@ -3395,7 +3400,7 @@ async def confirm_purchase( ) await callback.answer() return - + purchase_completed = False try: @@ -3403,7 +3408,7 @@ async def confirm_purchase( db, db_user, final_price, f"Покупка подписки на {data['period_days']} дней" ) - + if not success: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( @@ -3432,7 +3437,7 @@ async def confirm_purchase( ) await callback.answer() return - + existing_subscription = db_user.subscription was_trial_conversion = False current_time = datetime.utcnow() @@ -3468,10 +3473,11 @@ async def confirm_purchase( first_payment_amount_kopeks=final_price, first_paid_period_days=data['period_days'] ) - logger.info(f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price/100}₽") + logger.info( + f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽") except Exception as conversion_error: logger.error(f"Ошибка записи конверсии: {conversion_error}") - + existing_subscription.is_trial = False existing_subscription.status = SubscriptionStatus.ACTIVE.value existing_subscription.traffic_limit_gb = final_traffic_gb @@ -3487,7 +3493,7 @@ async def confirm_purchase( await db.commit() await db.refresh(existing_subscription) subscription = existing_subscription - + else: logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") subscription = await create_paid_subscription_with_traffic_mode( @@ -3498,25 +3504,25 @@ async def confirm_purchase( connected_squads=data['countries'], traffic_gb=final_traffic_gb ) - + from app.utils.user_utils import mark_user_as_had_paid_subscription await mark_user_as_had_paid_subscription(db, db_user) - + from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers from app.database.crud.subscription import add_subscription_servers - + server_ids = await get_server_ids_by_uuids(db, data['countries']) - + if server_ids: await add_subscription_servers(db, subscription, server_ids, server_prices) await add_user_to_servers(db, server_ids) - + logger.info(f"Сохранены цены серверов за весь период: {server_prices}") - + await db.refresh(db_user) - + subscription_service = SubscriptionService() - + if db_user.remnawave_uuid: remnawave_user = await subscription_service.update_remnawave_user( db, @@ -3531,7 +3537,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки", ) - + if not remnawave_user: logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") remnawave_user = await subscription_service.create_remnawave_user( @@ -3540,7 +3546,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки (повторная попытка)", ) - + transaction = await create_transaction( db=db, user_id=db_user.id, @@ -3548,7 +3554,7 @@ async def confirm_purchase( amount_kopeks=final_price, description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)" ) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_purchase_notification( @@ -3556,39 +3562,39 @@ async def confirm_purchase( ) except Exception as e: logger.error(f"Ошибка отправки уведомления о покупке: {e}") - + await db.refresh(db_user) await db.refresh(subscription) - + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_LINK_PROMPT", + "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) elif hide_subscription_link: success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f"{texts.SUBSCRIPTION_PURCHASED}\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", + "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + ) ) else: import_link_section = texts.t( @@ -3612,7 +3618,8 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=subscription_link), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "miniapp_custom": if not settings.MINIAPP_CUSTOM_URL: @@ -3632,7 +3639,8 @@ async def confirm_purchase( web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) elif connect_mode == "link": rows = [ @@ -3641,7 +3649,8 @@ async def confirm_purchase( happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) elif connect_mode == "happ_cryptolink": rows = [ @@ -3655,14 +3664,17 @@ async def confirm_purchase( happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]) + rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")]) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")], + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), + callback_data="back_to_menu")], ]) - + await callback.message.edit_text( success_text, reply_markup=connect_keyboard, @@ -3676,17 +3688,18 @@ async def confirm_purchase( ).format(purchase_text=texts.SUBSCRIPTION_PURCHASED), reply_markup=get_back_keyboard(db_user.language) ) - + purchase_completed = True - logger.info(f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽") - + logger.info( + f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽") + except Exception as e: logger.error(f"Ошибка покупки подписки: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + if purchase_completed: await clear_subscription_checkout_draft(db_user.id) @@ -3694,11 +3707,10 @@ async def confirm_purchase( await callback.answer() - async def resume_subscription_checkout( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, ): texts = get_texts(db_user.language) @@ -3729,19 +3741,21 @@ async def resume_subscription_checkout( ) await callback.answer() + + async def add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + base_price = settings.get_traffic_price(traffic_gb) if base_price == 0 and traffic_gb != 0: @@ -3797,7 +3811,7 @@ async def add_traffic( ) await callback.answer() return - + try: success = await subtract_user_balance( db, @@ -3805,19 +3819,19 @@ async def add_traffic( price, f"Добавление {traffic_gb} ГБ трафика", ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - - if traffic_gb == 0: + + if traffic_gb == 0: subscription.traffic_limit_gb = 0 else: await add_subscription_traffic(db, subscription, traffic_gb) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await create_transaction( db=db, user_id=db_user.id, @@ -3825,11 +3839,10 @@ async def add_traffic( amount_kopeks=price, description=f"Добавление {traffic_gb} ГБ трафика", ) - - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = f"✅ Трафик успешно добавлен!\n\n" if traffic_gb == 0: success_text += "🎉 Теперь у вас безлимитный трафик!" @@ -3849,36 +3862,37 @@ async def add_traffic( success_text, reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") - + except Exception as e: logger.error(f"Ошибка добавления трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() + async def create_paid_subscription_with_traffic_mode( - db: AsyncSession, - user_id: int, - duration_days: int, - device_limit: int, - connected_squads: List[str], - traffic_gb: Optional[int] = None + db: AsyncSession, + user_id: int, + duration_days: int, + device_limit: int, + connected_squads: List[str], + traffic_gb: Optional[int] = None ): from app.config import settings - + if traffic_gb is None: if settings.is_traffic_fixed(): traffic_limit_gb = settings.get_fixed_traffic_limit() else: - traffic_limit_gb = 0 + traffic_limit_gb = 0 else: traffic_limit_gb = traffic_gb - + subscription = await create_paid_subscription( db=db, user_id=user_id, @@ -3887,35 +3901,36 @@ async def create_paid_subscription_with_traffic_mode( device_limit=device_limit, connected_squads=connected_squads ) - + logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") - + return subscription + def validate_traffic_price(gb: int) -> bool: from app.config import settings - + price = settings.get_traffic_price(gb) - if gb == 0: + if gb == 0: return True - + return price > 0 async def handle_subscription_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True) return - + devices_used = await get_current_devices_count(db_user) - + settings_text = f""" ⚙️ Настройки подписки @@ -3926,9 +3941,9 @@ async def handle_subscription_settings( Выберите что хотите изменить: """ - + show_countries = await _should_show_countries_management(db_user) - + await callback.message.edit_text( settings_text, reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries), @@ -3938,24 +3953,23 @@ async def handle_subscription_settings( async def handle_autopay_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription if not subscription: await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) return - + status = "включен" if subscription.autopay_enabled else "выключен" days = subscription.autopay_days_before - + text = f"💳 Автоплатеж\n\n" text += f"📊 Статус: {status}\n" text += f"⏰ Списание за: {days} дн. до окончания\n\n" text += "Выберите действие:" - + await callback.message.edit_text( text, reply_markup=get_autopay_keyboard(db_user.language) @@ -3964,27 +3978,25 @@ async def handle_autopay_menu( async def toggle_autopay( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription enable = callback.data == "autopay_enable" - + await update_subscription_autopay(db, subscription, enable) - + status = "включен" if enable else "выключен" await callback.answer(f"✅ Автоплатеж {status}!") - + await handle_autopay_menu(callback, db_user, db) async def show_autopay_days( - callback: types.CallbackQuery, - db_user: User + callback: types.CallbackQuery, + db_user: User ): - await callback.message.edit_text( "⏰ Выберите за сколько дней до окончания списывать средства:", reply_markup=get_autopay_days_keyboard(db_user.language) @@ -3993,31 +4005,31 @@ async def show_autopay_days( async def set_autopay_days( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - days = int(callback.data.split('_')[2]) subscription = db_user.subscription - + await update_subscription_autopay( db, subscription, subscription.autopay_enabled, days ) - + await callback.answer(f"✅ Установлено {days} дней!") - + await handle_autopay_menu(callback, db_user, db) + async def handle_subscription_config_back( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession ): current_state = await state.get_state() texts = get_texts(db_user.language) - + if current_state == SubscriptionStates.selecting_traffic.state: await callback.message.edit_text( _build_subscription_period_prompt(db_user, texts), @@ -4062,21 +4074,21 @@ async def handle_subscription_config_back( reply_markup=get_subscription_period_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_period) - + else: from app.handlers.menu import show_main_menu await show_main_menu(callback, db_user, db) await state.clear() - + await callback.answer() -async def handle_subscription_cancel( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_subscription_cancel( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): texts = get_texts(db_user.language) await state.clear() @@ -4087,6 +4099,7 @@ async def handle_subscription_cancel( await callback.answer("❌ Покупка отменена") + async def _get_available_countries(promo_group_id: Optional[int] = None): from app.utils.cache import cache, cache_key from app.database.database import AsyncSessionLocal @@ -4115,23 +4128,24 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): for server in available_servers: countries.append({ "uuid": server.squad_uuid, - "name": server.display_name, + "name": server.display_name, "price_kopeks": server.price_kopeks, "country_code": server.country_code, "is_available": server.is_available and not server.is_full }) - + if not countries: logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") from app.services.remnawave_service import RemnaWaveService - + service = RemnaWaveService() squads = await service.get_all_squads() - + for squad in squads: squad_name = squad["name"] - - if not any(flag in squad_name for flag in ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): + + if not any(flag in squad_name for flag in + ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): name_lower = squad_name.lower() if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: squad_name = f"🇳🇱 {squad_name}" @@ -4141,14 +4155,14 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): squad_name = f"🇺🇸 {squad_name}" else: squad_name = f"🌐 {squad_name}" - + countries.append({ "uuid": squad["uuid"], "name": squad_name, - "price_kopeks": 0, + "price_kopeks": 0, "is_available": True }) - + await cache.set(cache_key_value, countries, 300) return countries @@ -4161,35 +4175,36 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): await cache.set(cache_key_value, fallback_countries, 60) return fallback_countries + async def _get_countries_info(squad_uuids): countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] + async def handle_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - await handle_device_management(callback, db_user, db) + async def handle_add_country_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): - logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}") logger.info(f"🔍 Callback data: {callback.data}") - + current_state = await state.get_state() logger.info(f"🔍 Текущее состояние: {current_state}") - + country_uuid = callback.data.split('_')[1] data = await state.get_data() logger.info(f"🔍 Данные состояния: {data}") - + selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) allowed_country_ids = {country['uuid'] for country in countries} @@ -4197,14 +4212,14 @@ async def handle_add_country_to_subscription( if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return - + if country_uuid in selected_countries: selected_countries.remove(country_uuid) logger.info(f"🔍 Удалена страна: {country_uuid}") else: selected_countries.append(country_uuid) logger.info(f"🔍 Добавлена страна: {country_uuid}") - + total_price = 0 subscription = db_user.subscription period_hint_days = _get_period_hint_from_subscription(subscription) @@ -4219,8 +4234,8 @@ async def handle_add_country_to_subscription( continue if ( - country['uuid'] in selected_countries - and country['uuid'] not in subscription.connected_squads + country['uuid'] in selected_countries + and country['uuid'] not in subscription.connected_squads ): server_price = country['price_kopeks'] if servers_discount_percent > 0 and server_price > 0: @@ -4238,7 +4253,7 @@ async def handle_add_country_to_subscription( logger.info(f"🔍 Новые выбранные страны: {selected_countries}") logger.info(f"🔍 Общая стоимость: {total_price}") - + try: from app.keyboards.inline import get_manage_countries_keyboard await callback.message.edit_reply_markup( @@ -4254,9 +4269,10 @@ async def handle_add_country_to_subscription( logger.info(f"✅ Клавиатура обновлена") except Exception as e: logger.error(f"❌ Ошибка обновления клавиатуры: {e}") - + await callback.answer() + async def _should_show_countries_management(user: Optional[User] = None) -> bool: try: promo_group_id = user.promo_group_id if user else None @@ -4294,16 +4310,15 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool async def confirm_add_countries_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext ): - data = await state.get_data() texts = get_texts(db_user.language) subscription = db_user.subscription - + selected_countries = data.get('countries', []) current_countries = subscription.connected_squads @@ -4318,11 +4333,11 @@ async def confirm_add_countries_to_subscription( new_countries = [c for c in selected_countries if c not in current_countries] removed_countries = [c for c in current_countries if c not in selected_countries] - + if not new_countries and not removed_countries: await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) return - + total_price = 0 new_countries_names = [] removed_countries_names = [] @@ -4360,7 +4375,7 @@ async def confirm_add_countries_to_subscription( new_countries_names.append(country['name']) if country['uuid'] in removed_countries: removed_countries_names.append(country['name']) - + if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( @@ -4389,18 +4404,18 @@ async def confirm_add_countries_to_subscription( await state.clear() await callback.answer() return - + try: if new_countries and total_price > 0: success = await subtract_user_balance( db, db_user, total_price, f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + if not success: await callback.answer("❌ Ошибка списания средств", show_alert=True) return - + await create_transaction( db=db, user_id=db_user.id, @@ -4408,19 +4423,19 @@ async def confirm_add_countries_to_subscription( amount_kopeks=total_price, description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" ) - + subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() await db.commit() subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + success_text = "✅ Страны успешно обновлены!\n\n" - + if new_countries_names: success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" if total_price > 0: @@ -4431,42 +4446,44 @@ async def confirm_add_countries_to_subscription( f" -{texts.format_price(total_discount_value)})" ) success_text += "\n" - + if removed_countries_names: success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" success_text += "ℹ️ Повторное подключение будет платным\n" - + success_text += f"\n🌍 Активных стран: {len(selected_countries)}" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") + except Exception as e: logger.error(f"Ошибка обновления стран подписки: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await state.clear() await callback.answer() -async def confirm_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_reset_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): await handle_device_management(callback, db_user, db) + async def handle_happ_download_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) prompt_text = texts.t( @@ -4481,9 +4498,9 @@ async def handle_happ_download_request( async def handle_happ_download_platform_choice( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): platform = callback.data.split('_')[-1] if platform == "pc": @@ -4517,9 +4534,9 @@ async def handle_happ_download_platform_choice( async def handle_happ_download_close( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): try: await callback.message.delete() @@ -4530,9 +4547,9 @@ async def handle_happ_download_close( async def handle_happ_download_back( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) prompt_text = texts.t( @@ -4545,10 +4562,11 @@ async def handle_happ_download_back( await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML") await callback.answer() + async def handle_connect_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription @@ -4707,14 +4725,14 @@ async def handle_connect_subscription( reply_markup=get_device_selection_keyboard(db_user.language), parse_mode="HTML" ) - + await callback.answer() async def claim_discount_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, ): texts = get_texts(db_user.language) @@ -4782,11 +4800,11 @@ async def claim_discount_offer( async def handle_device_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - device_type = callback.data.split('_')[2] + device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) @@ -4812,62 +4830,62 @@ async def handle_device_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + f"\n{subscription_link}\n\n" ) guide_text = ( - texts.t( - "SUBSCRIPTION_DEVICE_GUIDE_TITLE", - "📱 Настройка для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section - + texts.t( - "SUBSCRIPTION_DEVICE_FEATURED_APP", - "📋 Рекомендуемое приложение: {app_name}", - ).format(app_name=featured_app['name']) - + "\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") - + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") - + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") - + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1", - "1. Установите приложение по ссылке выше", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2", - "2. Скопируйте ссылку подписки (нажмите на неё)", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3", - "3. Откройте приложение и вставьте ссылку", - ), - texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4", - "4. Подключитесь к серверу", - ), - ] - ) + texts.t( + "SUBSCRIPTION_DEVICE_GUIDE_TITLE", + "📱 Настройка для {device_name}", + ).format(device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + link_section + + texts.t( + "SUBSCRIPTION_DEVICE_FEATURED_APP", + "📋 Рекомендуемое приложение: {app_name}", + ).format(app_name=featured_app['name']) + + "\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + + f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + + f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + + f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP1", + "1. Установите приложение по ссылке выше", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP2", + "2. Скопируйте ссылку подписки (нажмите на неё)", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP3", + "3. Откройте приложение и вставьте ссылку", + ), + texts.t( + "SUBSCRIPTION_DEVICE_HOW_TO_STEP4", + "4. Подключитесь к серверу", + ), + ] ) - + ) + await callback.message.edit_text( guide_text, reply_markup=get_connection_guide_keyboard( @@ -4881,16 +4899,16 @@ async def handle_device_guide( async def handle_app_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - device_type = callback.data.split('_')[2] + device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription - + apps = get_apps_for_device(device_type, db_user.language) - + if not apps: await callback.answer( texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"), @@ -4899,14 +4917,14 @@ async def handle_app_selection( return app_text = ( - texts.t( - "SUBSCRIPTION_APPS_TITLE", - "📱 Приложения для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:") + texts.t( + "SUBSCRIPTION_APPS_TITLE", + "📱 Приложения для {device_name}", + ).format(device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:") ) - + await callback.message.edit_text( app_text, reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language), @@ -4916,14 +4934,14 @@ async def handle_app_selection( async def handle_specific_app_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - _, device_type, app_id = callback.data.split('_') + _, device_type, app_id = callback.data.split('_') texts = get_texts(db_user.language) subscription = db_user.subscription - + subscription_link = get_display_subscription_link(subscription) if not subscription_link: @@ -4935,7 +4953,7 @@ async def handle_specific_app_guide( apps = get_apps_for_device(device_type, db_user.language) app = next((a for a in apps if a['id'] == app_id), None) - + if not app: await callback.answer( texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"), @@ -4947,46 +4965,46 @@ async def handle_specific_app_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n" + + texts.t( + "SUBSCRIPTION_LINK_HIDDEN_NOTICE", + "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + ) + + "\n\n" ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + f"\n{subscription_link}\n\n" ) guide_text = ( - texts.t( - "SUBSCRIPTION_SPECIFIC_APP_TITLE", - "📱 {app_name} - {device_name}", - ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section - + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") - + f"\n{app['installationStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") - + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n" - + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") - + f"\n{app['connectAndUseStep']['description'][db_user.language]}" + texts.t( + "SUBSCRIPTION_SPECIFIC_APP_TITLE", + "📱 {app_name} - {device_name}", + ).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language)) + + "\n\n" + + link_section + + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + + f"\n{app['installationStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + + f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n" + + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + + f"\n{app['connectAndUseStep']['description'][db_user.language]}" ) if 'additionalAfterAddSubscriptionStep' in app: additional = app['additionalAfterAddSubscriptionStep'] guide_text += ( - "\n\n" - + texts.t( - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE", - "{title}:", - ).format(title=additional['title'][db_user.language]) - + f"\n{additional['description'][db_user.language]}" + "\n\n" + + texts.t( + "SUBSCRIPTION_ADDITIONAL_STEP_TITLE", + "{title}:", + ).format(title=additional['title'][db_user.language]) + + f"\n{additional['description'][db_user.language]}" ) - + await callback.message.edit_text( guide_text, reply_markup=get_specific_app_keyboard( @@ -4999,21 +5017,22 @@ async def handle_specific_app_guide( ) await callback.answer() + async def handle_no_traffic_packages( - callback: types.CallbackQuery, - db_user: User + callback: types.CallbackQuery, + db_user: User ): await callback.answer( "⚠️ В данный момент нет доступных пакетов трафика. " - "Обратитесь в техподдержку для получения информации.", + "Обратитесь в техподдержку для получения информации.", show_alert=True ) async def handle_open_subscription_link( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) subscription = db_user.subscription @@ -5030,20 +5049,20 @@ async def handle_open_subscription_link( redirect_link = get_happ_cryptolink_redirect_link(subscription_link) happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link) happ_message = ( - texts.t( - "SUBSCRIPTION_HAPP_OPEN_TITLE", - "🔗 Подключение через Happ", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "🔓 Открыть ссылку в Happ", - ).format(subscription_link=happ_scheme_link) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_HINT", - "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", - ) + texts.t( + "SUBSCRIPTION_HAPP_OPEN_TITLE", + "🔗 Подключение через Happ", + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_LINK", + "🔓 Открыть ссылку в Happ", + ).format(subscription_link=happ_scheme_link) + + "\n\n" + + texts.t( + "SUBSCRIPTION_HAPP_OPEN_HINT", + "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", + ) ) if redirect_link: @@ -5073,43 +5092,44 @@ async def handle_open_subscription_link( return link_text = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n\n" - + f"{subscription_link}\n\n" - + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_LINK_STEP1", - "1. Нажмите на ссылку выше чтобы её скопировать", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP2", - "2. Откройте ваше VPN приложение", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP3", - "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP4", - "4. Вставьте скопированную ссылку", - ), - ] - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HINT", - "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - ) + texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") + + "\n\n" + + f"{subscription_link}\n\n" + + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") + + "\n" + + "\n".join( + [ + texts.t( + "SUBSCRIPTION_LINK_STEP1", + "1. Нажмите на ссылку выше чтобы её скопировать", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP2", + "2. Откройте ваше VPN приложение", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP3", + "3. Найдите функцию \"Добавить подписку\" или \"Import\"", + ), + texts.t( + "SUBSCRIPTION_LINK_STEP4", + "4. Вставьте скопированную ссылку", + ), + ] + ) + + "\n\n" + + texts.t( + "SUBSCRIPTION_LINK_HINT", + "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", + ) ) await callback.message.edit_text( link_text, reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") + InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + callback_data="subscription_connect") ], [ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") @@ -5124,7 +5144,7 @@ def load_app_config() -> Dict[str, Any]: try: from app.config import settings config_path = settings.get_app_config_path() - + with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: @@ -5133,16 +5153,16 @@ def load_app_config() -> Dict[str, Any]: def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]: - config = load_app_config() - + config = load_app_config()['platforms'] + device_mapping = { 'ios': 'ios', - 'android': 'android', - 'windows': 'pc', - 'mac': 'pc', - 'tv': 'tv' + 'android': 'android', + 'windows': 'windows', + 'mac': 'macos', + 'tv': 'androidTV' } - + config_key = device_mapping.get(device_type, device_type) return config.get(config_key, []) @@ -5164,13 +5184,11 @@ def get_device_name(device_type: str, language: str = "ru") -> str: 'mac': 'macOS', 'tv': 'Android TV' } - + return names.get(device_type, device_type) def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str: - from app.config import settings - return subscription_url @@ -5179,7 +5197,7 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( - text="✅ Да, сбросить все устройства", + text="✅ Да, сбросить все устройства", callback_data="confirm_reset_devices" ) ], @@ -5188,19 +5206,21 @@ def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMa ] ]) -async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, subscription: Subscription): + +async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, + subscription: Subscription): try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: logger.error(f"Ошибка отправки уведомления о триале: {e}") + async def show_device_connection_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) @@ -5230,7 +5250,7 @@ async def show_device_connection_help( 💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств """ - + await callback.message.edit_text( help_text, reply_markup=get_device_management_help_keyboard(db_user.language), @@ -5238,18 +5258,19 @@ async def show_device_connection_help( ) await callback.answer() + async def send_purchase_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - period_days: int, - was_trial_conversion: bool = False + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + period_days: int, + was_trial_conversion: bool = False ): try: from app.database.crud.transaction import get_transaction_by_id - + transaction = await get_transaction_by_id(db, transaction_id) if transaction: notification_service = AdminNotificationService(callback.bot) @@ -5259,18 +5280,19 @@ async def send_purchase_notification( except Exception as e: logger.error(f"Ошибка отправки уведомления о покупке: {e}") + async def send_extension_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - extended_days: int, - old_end_date: datetime + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + extended_days: int, + old_end_date: datetime ): try: from app.database.crud.transaction import get_transaction_by_id - + transaction = await get_transaction_by_id(db, transaction_id) if transaction: notification_service = AdminNotificationService(callback.bot) @@ -5280,24 +5302,25 @@ async def send_extension_notification( except Exception as e: logger.error(f"Ошибка отправки уведомления о продлении: {e}") + async def handle_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): from app.config import settings - + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return - + texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription or subscription.is_trial: await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return - + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -5321,26 +5344,25 @@ async def handle_switch_traffic( ), parse_mode="HTML" ) - + await callback.answer() async def confirm_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + current_traffic = subscription.traffic_limit_gb - + if new_traffic_gb == current_traffic: await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True) return - + old_price_per_month = settings.get_traffic_price(current_traffic) new_price_per_month = settings.get_traffic_price(new_traffic_gb) @@ -5362,9 +5384,9 @@ async def confirm_switch_traffic( ) price_difference_per_month = discounted_new_per_month - discounted_old_per_month discount_savings_per_month = ( - (new_price_per_month - old_price_per_month) - price_difference_per_month + (new_price_per_month - old_price_per_month) - price_difference_per_month ) - + if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining @@ -5395,7 +5417,7 @@ async def confirm_switch_traffic( ) await callback.answer() return - + action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}" cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)" if discount_savings_per_month > 0: @@ -5408,61 +5430,61 @@ async def confirm_switch_traffic( total_price_difference = 0 action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}" cost_text = "Возврат средств не производится" - + confirm_text = f"🔄 Подтверждение переключения трафика\n\n" confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n" confirm_text += f"Действие: {action_text}\n" confirm_text += f"💰 {cost_text}\n\n" confirm_text += "Подтвердить переключение?" - + await callback.message.edit_text( confirm_text, reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language), parse_mode="HTML" ) - + await callback.answer() + async def clear_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + 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, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession ): - callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) price_difference = int(callback_parts[4]) - + texts = get_texts(db_user.language) subscription = db_user.subscription current_traffic = subscription.traffic_limit_gb - + try: if price_difference > 0: success = await subtract_user_balance( db, db_user, price_difference, f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB" ) - + if not success: await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return - + months_remaining = get_remaining_months(subscription.end_date) await create_transaction( db=db, @@ -5471,18 +5493,18 @@ async def execute_switch_traffic( amount_kopeks=price_difference, description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес" ) - + subscription.traffic_limit_gb = new_traffic_gb subscription.updated_at = datetime.utcnow() - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + await db.refresh(db_user) await db.refresh(subscription) - + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(callback.bot) @@ -5491,7 +5513,7 @@ async def execute_switch_traffic( ) except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}") - + if new_traffic_gb > current_traffic: success_text = f"✅ Лимит трафика увеличен!\n\n" success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " @@ -5503,50 +5525,51 @@ async def execute_switch_traffic( success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" success_text += f"ℹ️ Возврат средств не производится" - + await callback.message.edit_text( success_text, reply_markup=get_back_keyboard(db_user.language) ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference/100}₽") - + + logger.info( + f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽") + except Exception as e: logger.error(f"Ошибка переключения трафика: {e}") await callback.message.edit_text( texts.ERROR, reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() def get_traffic_switch_keyboard( - current_traffic_gb: int, - language: str = "ru", - subscription_end_date: datetime = None, - discount_percent: int = 0, + current_traffic_gb: int, + language: str = "ru", + subscription_end_date: datetime = None, + discount_percent: int = 0, ) -> InlineKeyboardMarkup: from app.config import settings - + months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: period_text = f" (за {months_multiplier} мес)" - + packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + current_price_per_month = settings.get_traffic_price(current_traffic_gb) discounted_current_per_month, _ = apply_percentage_discount( current_price_per_month, discount_percent, ) - + buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] @@ -5565,14 +5588,14 @@ def get_traffic_switch_keyboard( elif total_price_diff > 0: emoji = "⬆️" action_text = "" - price_text = f" (+{total_price_diff//100}₽{period_text})" + price_text = f" (+{total_price_diff // 100}₽{period_text})" if discount_percent > 0: discount_total = ( - (price_per_month - current_price_per_month) * months_multiplier - - total_price_diff + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff ) if discount_total > 0: - price_text += f" (скидка {discount_percent}%: -{discount_total//100}₽)" + price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)" elif total_price_diff < 0: emoji = "⬇️" action_text = "" @@ -5581,34 +5604,33 @@ def get_traffic_switch_keyboard( emoji = "🔄" action_text = "" price_text = " (бесплатно)" - + if gb == 0: traffic_text = "Безлимит" else: traffic_text = f"{gb} ГБ" - + button_text = f"{emoji} {traffic_text}{action_text}{price_text}" - + buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}") ]) - + buttons.append([ InlineKeyboardButton( text="⬅️ Назад" if language == "ru" else "⬅️ Back", callback_data="subscription_settings" ) ]) - + return InlineKeyboardMarkup(inline_keyboard=buttons) def get_confirm_switch_traffic_keyboard( - new_traffic_gb: int, - price_difference: int, - language: str = "ru" + new_traffic_gb: int, + price_difference: int, + language: str = "ru" ) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( @@ -5627,32 +5649,32 @@ def get_confirm_switch_traffic_keyboard( def register_handlers(dp: Dispatcher): update_traffic_prices() - + dp.callback_query.register( show_subscription_info, F.data == "menu_subscription" ) - + dp.callback_query.register( show_trial_offer, F.data == "menu_trial" ) - + dp.callback_query.register( activate_trial, F.data == "trial_activate" ) - + dp.callback_query.register( start_subscription_purchase, F.data.in_(["menu_buy", "subscription_upgrade"]) ) - + dp.callback_query.register( handle_add_countries, F.data == "subscription_add_countries" ) - + dp.callback_query.register( handle_switch_traffic, F.data == "subscription_switch_traffic" @@ -5662,12 +5684,12 @@ def register_handlers(dp: Dispatcher): confirm_switch_traffic, F.data.startswith("switch_traffic_") ) - + dp.callback_query.register( execute_switch_traffic, F.data.startswith("confirm_switch_traffic_") ) - + dp.callback_query.register( handle_change_devices, F.data == "subscription_change_devices" @@ -5677,33 +5699,32 @@ def register_handlers(dp: Dispatcher): confirm_change_devices, F.data.startswith("change_devices_") ) - + dp.callback_query.register( execute_change_devices, F.data.startswith("confirm_change_devices_") ) - + dp.callback_query.register( handle_extend_subscription, F.data == "subscription_extend" ) - + dp.callback_query.register( handle_reset_traffic, F.data == "subscription_reset_traffic" ) - - + dp.callback_query.register( confirm_add_devices, F.data.startswith("add_devices_") ) - + dp.callback_query.register( confirm_extend_subscription, F.data.startswith("extend_period_") ) - + dp.callback_query.register( confirm_reset_traffic, F.data == "confirm_reset_traffic" @@ -5713,36 +5734,36 @@ def register_handlers(dp: Dispatcher): handle_reset_devices, F.data == "subscription_reset_devices" ) - + dp.callback_query.register( confirm_reset_devices, F.data == "confirm_reset_devices" ) - + dp.callback_query.register( select_period, F.data.startswith("period_"), SubscriptionStates.selecting_period ) - + dp.callback_query.register( select_traffic, F.data.startswith("traffic_"), SubscriptionStates.selecting_traffic ) - + dp.callback_query.register( select_devices, F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]), SubscriptionStates.selecting_devices ) - + dp.callback_query.register( devices_continue, F.data == "devices_continue", SubscriptionStates.selecting_devices ) - + dp.callback_query.register( confirm_purchase, F.data == "subscription_confirm", @@ -5763,17 +5784,17 @@ def register_handlers(dp: Dispatcher): clear_saved_cart, F.data == "clear_saved_cart", ) - + dp.callback_query.register( handle_autopay_menu, F.data == "subscription_autopay" ) - + dp.callback_query.register( toggle_autopay, F.data.in_(["autopay_enable", "autopay_disable"]) ) - + dp.callback_query.register( show_autopay_days, F.data == "autopay_set_days" @@ -5783,12 +5804,12 @@ def register_handlers(dp: Dispatcher): handle_subscription_config_back, F.data == "subscription_config_back" ) - + dp.callback_query.register( handle_subscription_cancel, F.data == "subscription_cancel" ) - + dp.callback_query.register( set_autopay_days, F.data.startswith("autopay_days_") @@ -5799,7 +5820,7 @@ def register_handlers(dp: Dispatcher): F.data.startswith("country_"), SubscriptionStates.selecting_countries ) - + dp.callback_query.register( countries_continue, F.data == "countries_continue", @@ -5810,7 +5831,7 @@ def register_handlers(dp: Dispatcher): handle_manage_country, F.data.startswith("country_manage_") ) - + dp.callback_query.register( apply_countries_changes, F.data == "countries_apply" @@ -5851,22 +5872,22 @@ def register_handlers(dp: Dispatcher): handle_connect_subscription, F.data == "subscription_connect" ) - + dp.callback_query.register( handle_device_guide, F.data.startswith("device_guide_") ) - + dp.callback_query.register( handle_app_selection, F.data.startswith("app_list_") ) - + dp.callback_query.register( handle_specific_app_guide, F.data.startswith("app_") ) - + dp.callback_query.register( handle_open_subscription_link, F.data == "open_subscription_link" @@ -5886,17 +5907,17 @@ def register_handlers(dp: Dispatcher): handle_device_management, F.data == "subscription_manage_devices" ) - + dp.callback_query.register( handle_devices_page, F.data.startswith("devices_page_") ) - + dp.callback_query.register( handle_single_device_reset, - F.data.regexp(r"^reset_device_\d+_\d+$") + F.data.regexp(r"^reset_device_\d+_\d+$") ) - + dp.callback_query.register( handle_all_devices_reset_from_management, F.data == "reset_all_devices" @@ -5905,4 +5926,4 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( show_device_connection_help, F.data == "device_connection_help" - ) + ) \ No newline at end of file From 8774b53570675ea7934fc87d38abc7b597a627d5 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 30 Sep 2025 18:45:44 +0300 Subject: [PATCH 07/24] fix /start moderator --- app/handlers/menu.py | 18 ++++++++--- app/handlers/start.py | 74 +++++++++++++++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a191d537..d073f0dd 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -44,13 +44,18 @@ async def show_main_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + is_admin = settings.is_admin(db_user.telegram_id) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator( + db_user.telegram_id + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, keyboard=get_main_menu_keyboard( language=db_user.language, - is_admin=settings.is_admin(db_user.telegram_id), - is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), + is_admin=is_admin, + is_moderator=is_moderator, has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, @@ -191,13 +196,18 @@ async def handle_back_to_menu( draft_exists = await has_subscription_checkout_draft(db_user.id) show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists) + is_admin = settings.is_admin(db_user.telegram_id) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator( + db_user.telegram_id + ) + await edit_or_answer_photo( callback=callback, caption=menu_text, keyboard=get_main_menu_keyboard( language=db_user.language, - is_admin=settings.is_admin(db_user.telegram_id), - is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)), + is_admin=is_admin, + is_moderator=is_moderator, has_had_paid_subscription=db_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, diff --git a/app/handlers/start.py b/app/handlers/start.py index 780f44c3..92187a1f 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -30,6 +30,7 @@ from app.services.referral_service import process_referral_registration from app.services.campaign_service import AdvertisingCampaignService from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_service import SubscriptionService +from app.services.support_settings_service import SupportSettingsService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message @@ -322,17 +323,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) - + + is_admin = settings.is_admin(user.telegram_id) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator( + user.telegram_id + ) + await message.answer( menu_text, reply_markup=get_main_menu_keyboard( language=user.language, - is_admin=settings.is_admin(user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, - subscription=user.subscription + subscription=user.subscription, + is_moderator=is_moderator, ), parse_mode="HTML" ) @@ -728,18 +735,25 @@ async def complete_registration_from_callback( subscription_is_active = existing_user.subscription.is_active menu_text = await get_main_menu_text(existing_user, texts, db) - + + is_admin = settings.is_admin(existing_user.telegram_id) + is_moderator = ( + (not is_admin) + and SupportSettingsService.is_moderator(existing_user.telegram_id) + ) + try: await callback.message.answer( menu_text, reply_markup=get_main_menu_keyboard( language=existing_user.language, - is_admin=settings.is_admin(existing_user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=existing_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription + subscription=existing_user.subscription, + is_moderator=is_moderator, ), parse_mode="HTML" ) @@ -893,18 +907,25 @@ async def complete_registration_from_callback( subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) - + + is_admin = settings.is_admin(user.telegram_id) + is_moderator = ( + (not is_admin) + and SupportSettingsService.is_moderator(user.telegram_id) + ) + try: await callback.message.answer( menu_text, reply_markup=get_main_menu_keyboard( language=user.language, - is_admin=settings.is_admin(user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, - subscription=user.subscription + subscription=user.subscription, + is_moderator=is_moderator, ), parse_mode="HTML" ) @@ -952,18 +973,25 @@ async def complete_registration( subscription_is_active = existing_user.subscription.is_active menu_text = await get_main_menu_text(existing_user, texts, db) - + + is_admin = settings.is_admin(existing_user.telegram_id) + is_moderator = ( + (not is_admin) + and SupportSettingsService.is_moderator(existing_user.telegram_id) + ) + try: await message.answer( menu_text, reply_markup=get_main_menu_keyboard( language=existing_user.language, - is_admin=settings.is_admin(existing_user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=existing_user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=existing_user.balance_kopeks, - subscription=existing_user.subscription + subscription=existing_user.subscription, + is_moderator=is_moderator, ), parse_mode="HTML" ) @@ -1117,18 +1145,25 @@ async def complete_registration( subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) - + + is_admin = settings.is_admin(user.telegram_id) + is_moderator = ( + (not is_admin) + and SupportSettingsService.is_moderator(user.telegram_id) + ) + try: await message.answer( menu_text, reply_markup=get_main_menu_keyboard( language=user.language, - is_admin=settings.is_admin(user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, - subscription=user.subscription + subscription=user.subscription, + is_moderator=is_moderator, ), parse_mode="HTML" ) @@ -1357,14 +1392,21 @@ async def required_sub_channel_check( from app.utils.message_patch import LOGO_PATH from aiogram.types import FSInputFile + is_admin = settings.is_admin(user.telegram_id) + is_moderator = ( + (not is_admin) + and SupportSettingsService.is_moderator(user.telegram_id) + ) + keyboard = get_main_menu_keyboard( language=user.language, - is_admin=settings.is_admin(user.telegram_id), + is_admin=is_admin, has_had_paid_subscription=user.has_had_paid_subscription, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, subscription=user.subscription, + is_moderator=is_moderator, ) if settings.ENABLE_LOGO_MODE: From 46eb93dabe7cc8b8d7df00877bf8c170b3d59496 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 21:07:37 +0300 Subject: [PATCH 08/24] Revert "Resolve duplicate handlers and cleanup imports" --- app/database/models.py | 3 +- app/external/remnawave_api.py | 1 + app/handlers/admin/main.py | 1 + app/handlers/admin/support_settings.py | 19 +++- app/handlers/admin/welcome_text.py | 61 +++++----- app/handlers/menu.py | 12 ++ app/handlers/subscription.py | 24 ++++ app/handlers/tickets.py | 2 + app/services/admin_notification_service.py | 107 +++++++++++++++++- app/services/notification_settings_service.py | 1 + app/services/remnawave_service.py | 18 +++ app/utils/user_utils.py | 2 +- 12 files changed, 217 insertions(+), 34 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 26c4f860..d7f93679 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -571,7 +571,8 @@ class Subscription(Base): return 0.0 def extend_subscription(self, days: int): - + from datetime import timedelta, datetime + if self.end_date > datetime.utcnow(): self.end_date = self.end_date + timedelta(days=days) else: diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 19f42d91..8949dad9 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -4,6 +4,7 @@ import ssl import base64 from datetime import datetime, timedelta from typing import Dict, List, Optional, Union, Any +from urllib.parse import urlparse import aiohttp import logging from dataclasses import dataclass diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 3be7d267..aeaf15e1 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -128,6 +128,7 @@ async def show_support_submenu( # Moderators have access only to tickets and not to settings is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + from app.keyboards.admin import get_admin_support_submenu_keyboard kb = get_admin_support_submenu_keyboard(db_user.language) if is_moderator_only: # Rebuild keyboard to include only tickets and back to main menu diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index 5ab4d3af..adbf3ea7 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -4,7 +4,6 @@ import html import contextlib from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User @@ -153,6 +152,24 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess await show_support_settings(callback, db_user, db) +from app.states import SupportSettingsStates + +@admin_required +@error_handler +async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + await callback.message.edit_text( + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + ) + ) + await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below + # we'll manage separate state below + + +from aiogram.fsm.state import State, StatesGroup + class SupportAdvancedStates(StatesGroup): waiting_for_sla_minutes = State() waiting_for_moderator_id = State() diff --git a/app/handlers/admin/welcome_text.py b/app/handlers/admin/welcome_text.py index f4c2b353..aca691f9 100644 --- a/app/handlers/admin/welcome_text.py +++ b/app/handlers/admin/welcome_text.py @@ -3,6 +3,7 @@ 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.models import User from app.states import AdminStates from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard @@ -43,16 +44,16 @@ async def show_welcome_text_panel( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" await callback.message.edit_text( f"👋 Управление приветственным текстом\n\n" f"{status_emoji} Статус: {status_text}\n\n" f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n" f"💡 Доступные плейсхолдеры для автозамены:", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -88,11 +89,11 @@ async def show_current_welcome_text( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - current_text = welcome_settings['text'] - is_enabled = welcome_settings['is_enabled'] - - if not welcome_settings['id']: + settings = await get_current_welcome_text_settings(db) + current_text = settings['text'] + is_enabled = settings['is_enabled'] + + if not settings['id']: status = "📝 Используется стандартный текст:" else: status = "📝 Текущий приветственный текст:" @@ -120,7 +121,7 @@ async def show_placeholders_help( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key}\n {desc}" for key, desc in placeholders.items()]) @@ -136,7 +137,7 @@ async def show_placeholders_help( await callback.message.edit_text( help_text, - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -148,12 +149,12 @@ async def show_formatting_help( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) formatting_info = get_telegram_formatting_info() await callback.message.edit_text( formatting_info, - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) await callback.answer() @@ -166,8 +167,8 @@ async def start_edit_welcome_text( db_user: User, db: AsyncSession ): - welcome_settings = await get_current_welcome_text_settings(db) - current_text = welcome_settings['text'] + settings = await get_current_welcome_text_settings(db) + current_text = settings['text'] placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key} - {desc}" for key, desc in placeholders.items()]) @@ -205,9 +206,9 @@ async def process_welcome_text_edit( success = await set_welcome_text(db, new_text, db_user.id) if success: - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" placeholders = get_available_placeholders() placeholders_text = "\n".join([f"• {key}" for key in placeholders.keys()]) @@ -218,14 +219,14 @@ async def process_welcome_text_edit( f"Новый текст:\n" f"{new_text}\n\n" f"💡 Будут заменяться плейсхолдеры: {placeholders_text}", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) await message.answer( "❌ Ошибка при сохранении текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']) ) await state.clear() @@ -241,9 +242,9 @@ async def reset_welcome_text( success = await set_welcome_text(db, default_text, db_user.id) if success: - welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" + settings = await get_current_welcome_text_settings(db) + status_emoji = "🟢" if settings['is_enabled'] else "🔴" + status_text = "включено" if settings['is_enabled'] else "отключено" await callback.message.edit_text( f"✅ Приветственный текст сброшен на стандартный!\n\n" @@ -251,14 +252,14 @@ async def reset_welcome_text( f"Стандартный текст:\n" f"{default_text}\n\n" f"💡 Плейсхолдер {{user_name}} будет заменяться на имя пользователя", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) await callback.message.edit_text( "❌ Ошибка при сбросе текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']) ) await callback.answer() @@ -280,14 +281,14 @@ async def show_preview_welcome_text( test_user = TestUser() preview_text = await get_welcome_text_for_user(db, test_user) - welcome_settings = await get_current_welcome_text_settings(db) + settings = await get_current_welcome_text_settings(db) if preview_text: await callback.message.edit_text( f"👁️ Предварительный просмотр\n\n" f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n" f"{preview_text}", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) else: @@ -295,7 +296,7 @@ async def show_preview_welcome_text( f"👁️ Предварительный просмотр\n\n" f"🔴 Приветственные сообщения отключены.\n" f"Новые пользователи не будут получать приветственный текст после регистрации.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), + reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']), parse_mode="HTML" ) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index a191d537..7c8d56d4 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -10,6 +10,7 @@ from app.database.crud.user import get_user_by_telegram_id, update_user from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard from app.localization.texts import get_texts, get_rules 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, @@ -64,6 +65,17 @@ async def show_main_menu( await callback.answer() +async def mark_user_as_had_paid_subscription( + db: AsyncSession, + user: User +) -> None: + if not user.has_had_paid_subscription: + user.has_had_paid_subscription = True + user.updated_at = datetime.utcnow() + await db.commit() + logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку") + + async def show_service_rules( callback: types.CallbackQuery, db_user: User, diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 8c2ae0b4..f0b21c87 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -146,6 +146,12 @@ async def _prepare_subscription_summary( 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, + apply_percentage_discount, + ) summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -1122,6 +1128,7 @@ async def return_to_saved_cart( ) return + from app.utils.pricing_utils import calculate_months_from_days, format_period_description countries = await _get_available_countries(db_user.promo_group_id) selected_countries_names = [] @@ -1326,6 +1333,7 @@ async def apply_countries_changes( db: AsyncSession, state: FSMContext ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price logger.info(f"🔧 Применение изменений стран") @@ -1626,6 +1634,7 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1739,6 +1748,7 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) @@ -1869,6 +1879,7 @@ async def show_devices_page( page: int = 1 ): + from app.utils.pagination import paginate_list texts = get_texts(db_user.language) devices_per_page = 5 @@ -1967,6 +1978,7 @@ async def handle_single_device_reset( if response and 'response' in response: devices_list = response['response'].get('devices', []) + from app.utils.pagination import paginate_list devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) @@ -2272,6 +2284,7 @@ async def confirm_add_devices( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -2407,6 +2420,11 @@ async def confirm_extend_subscription( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import ( + calculate_months_from_days, + validate_pricing_calculation, + apply_percentage_discount, + ) from app.services.admin_notification_service import AdminNotificationService days = int(callback.data.split('_')[2]) @@ -3016,6 +3034,7 @@ async def select_country( return period_base_price = PERIOD_PRICES[data['period_days']] + from app.utils.pricing_utils import apply_percentage_discount discounted_base_price, _ = apply_percentage_discount( period_base_price, @@ -3151,6 +3170,7 @@ async def confirm_purchase( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation from app.services.admin_notification_service import AdminNotificationService data = await state.get_data() @@ -3870,6 +3890,7 @@ async def create_paid_subscription_with_traffic_mode( traffic_gb: Optional[int] = None ): from app.config import settings + from app.database.crud.subscription import create_paid_subscription if traffic_gb is None: if settings.is_traffic_fixed(): @@ -5330,6 +5351,7 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -5443,6 +5465,7 @@ async def execute_switch_traffic( db_user: User, db: AsyncSession ): + from app.utils.pricing_utils import get_remaining_months callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) @@ -5527,6 +5550,7 @@ def get_traffic_switch_keyboard( subscription_end_date: datetime = None, discount_percent: int = 0, ) -> InlineKeyboardMarkup: + from app.utils.pricing_utils import get_remaining_months from app.config import settings months_multiplier = 1 diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index f4f5e301..a6445a00 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -412,6 +412,7 @@ async def show_my_tickets( # Добавим кнопку перехода к закрытым keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) + from app.utils.photo_message import edit_or_answer_photo await edit_or_answer_photo( callback=callback, caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), @@ -455,6 +456,7 @@ async def show_my_tickets_closed( data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_") kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) + from app.utils.photo_message import edit_or_answer_photo await edit_or_answer_photo( callback=callback, caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 1aa3efeb..ad3ba206 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -701,7 +701,7 @@ class AdminNotificationService: if not payment_method: return '💰 С баланса' - return method_names.get(payment_method, '💰 С баланса') + return method_names.get(payment_method, f'💰 С баланса') def _format_traffic(self, traffic_gb: int) -> str: if traffic_gb == 0: @@ -748,32 +748,40 @@ class AdminNotificationService: if details.get("auto_enabled", False): icon = "⚠️" title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "warning" else: icon = "🔧" title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "info" elif event_type == "disable": icon = "✅" title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ" + alert_type = "success" elif event_type == "api_status": if status == "online": icon = "🟢" title = "API REMNAWAVE ВОССТАНОВЛЕНО" + alert_type = "success" else: icon = "🔴" title = "API REMNAWAVE НЕДОСТУПНО" + alert_type = "error" elif event_type == "monitoring": if status == "started": icon = "🔍" title = "МОНИТОРИНГ ЗАПУЩЕН" + alert_type = "info" else: icon = "⏹️" title = "МОНИТОРИНГ ОСТАНОВЛЕН" + alert_type = "info" else: icon = "ℹ️" title = "СИСТЕМА ТЕХРАБОТ" + alert_type = "info" message_parts = [f"{icon} {title}", ""] @@ -963,6 +971,103 @@ class AdminNotificationService: logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}") return False + async def send_remnawave_panel_status_notification( + self, + status: str, + details: Dict[str, Any] = None + ) -> bool: + if not self._is_enabled(): + return False + + try: + details = details or {} + + status_config = { + "online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"}, + "offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"}, + "degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"}, + "maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"} + } + + config = status_config.get(status, status_config["offline"]) + + message_parts = [ + f"{config['icon']} {config['title']}", + "" + ] + + if details.get("api_url"): + message_parts.append(f"🔗 URL: {details['api_url']}") + + if details.get("response_time"): + message_parts.append(f"⚡ Время отклика: {details['response_time']} сек") + + if details.get("last_check"): + last_check = details["last_check"] + if isinstance(last_check, str): + from datetime import datetime + last_check = datetime.fromisoformat(last_check) + message_parts.append(f"🕐 Последняя проверка: {last_check.strftime('%H:%M:%S')}") + + if status == "online": + if details.get("uptime"): + message_parts.append(f"⏱️ Время работы: {details['uptime']}") + + if details.get("users_online"): + message_parts.append(f"👥 Пользователей онлайн: {details['users_online']}") + + message_parts.append("") + message_parts.append("✅ Все системы работают нормально.") + + elif status == "offline": + if details.get("error"): + error_msg = str(details["error"])[:150] + message_parts.append(f"❌ Ошибка: {error_msg}") + + if details.get("consecutive_failures"): + message_parts.append(f"🔄 Неудачных попыток: {details['consecutive_failures']}") + + message_parts.append("") + message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.") + + elif status == "degraded": + if details.get("issues"): + issues = details["issues"] + if isinstance(issues, list): + message_parts.append("⚠️ Обнаруженные проблемы:") + for issue in issues[:3]: + message_parts.append(f" • {issue}") + else: + message_parts.append(f"⚠️ Проблема: {issues}") + + message_parts.append("") + message_parts.append("Панель работает, но возможны задержки или сбои.") + + elif status == "maintenance": + if details.get("maintenance_reason"): + message_parts.append(f"🔧 Причина: {details['maintenance_reason']}") + + if details.get("estimated_duration"): + message_parts.append(f"⏰ Ожидаемая длительность: {details['estimated_duration']}") + + if details.get("manual_message"): + message_parts.append(f"💬 Сообщение: {details['manual_message']}") + + message_parts.append("") + message_parts.append("Панель временно недоступна для обслуживания.") + + from datetime import datetime + message_parts.append("") + message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + + message = "\n".join(message_parts) + + return await self._send_message(message) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}") + return False + async def send_subscription_update_notification( self, db: AsyncSession, diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py index 959bf93e..a19edffd 100644 --- a/app/services/notification_settings_service.py +++ b/app/services/notification_settings_service.py @@ -1,4 +1,5 @@ import json +import json import logging from copy import deepcopy from pathlib import Path diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 9acaf501..1a02a08f 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -987,6 +987,15 @@ class RemnaWaveService: logger.error(f"Error removing users from squad: {e}") return False + async def delete_squad(self, squad_uuid: str) -> bool: + try: + async with self.get_api_client() as api: + response = await api.delete_internal_squad(squad_uuid) + return response + except Exception as e: + logger.error(f"Error deleting squad: {e}") + return False + async def get_all_inbounds(self) -> List[Dict]: try: async with self.get_api_client() as api: @@ -1021,6 +1030,15 @@ class RemnaWaveService: logger.error(f"Error renaming squad: {e}") return False + async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool: + try: + async with self.get_api_client() as api: + squad = await api.create_internal_squad(name, inbound_uuids) + return squad is not None + except Exception as e: + logger.error(f"Error creating squad: {e}") + return False + async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]: try: async with self.get_api_client() as api: diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index e2a73c49..8292f5e1 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -189,7 +189,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int and_( Transaction.user_id == referral.id, Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True) + Transaction.is_completed == True ) ) ) From 8fe9a3dbe2b4142dbdc16c6b76d4afd4c906b4d7 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 21:44:49 +0300 Subject: [PATCH 09/24] feat: notify trial users when leaving channel --- app/database/crud/notification.py | 14 +++ app/handlers/admin/monitoring.py | 58 ++++++++++++ app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/services/monitoring_service.py | 90 +++++++++++++++++++ app/services/notification_settings_service.py | 9 ++ locales/en.json | 1 + locales/ru.json | 1 + 8 files changed, 175 insertions(+) diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py index d59b6190..ccbd8be7 100644 --- a/app/database/crud/notification.py +++ b/app/database/crud/notification.py @@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None: ) ) await db.commit() + + +async def clear_notification_by_type( + db: AsyncSession, + subscription_id: int, + notification_type: str, +) -> None: + await db.execute( + delete(SentNotification).where( + SentNotification.subscription_id == subscription_id, + SentNotification.notification_type == notification_type, + ) + ) + await db.commit() diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index e707b2ac..7c8c9371 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str): trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + trial_channel_status = _format_toggle( + config["trial_channel_unsubscribed"].get("enabled", True) + ) expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) @@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str): "🔔 Уведомления пользователям\n\n" f"• 1 час после триала: {trial_1h_status}\n" f"• 24 часа после триала: {trial_24h_status}\n" + f"• Отписка от канала: {trial_channel_status}\n" f"• 1 день после истечения: {expired_1d_status}\n" f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" @@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str): [InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")], [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], [InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")], + [InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")], + [InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")], [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], [InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")], [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], @@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str): ], ] ) + elif notification_type == "trial_channel_unsubscribed": + template = texts.get( + "TRIAL_CHANNEL_UNSUBSCRIBED", + ( + "🚫 Доступ приостановлен\n\n" + "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n" + "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ." + ), + ) + check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался") + message = template.format(check_button=check_button) + buttons: list[list[InlineKeyboardButton]] = [] + if settings.CHANNEL_LINK: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), + url=settings.CHANNEL_LINK, + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text=check_button, + callback_data="sub_channel_check", + ) + ] + ) + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) elif notification_type == "expired_1d": template = texts.get( "SUBSCRIPTION_EXPIRED_1D", @@ -461,6 +497,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery): await callback.answer("❌ Не удалось отправить тест", show_alert=True) +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel") +@admin_required +async def toggle_trial_channel_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled() + NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel") +@admin_required +async def preview_trial_channel_notification(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed") + await callback.answer("✅ Пример отправлен") + except Exception as exc: + logger.error("Failed to send trial channel preview: %s", exc) + await callback.answer("❌ Не удалось отправить тест", show_alert=True) + + @router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") @admin_required async def toggle_expired_1d_notification(callback: CallbackQuery): @@ -533,6 +590,7 @@ async def preview_all_notifications(callback: CallbackQuery): for notification_type in [ "trial_inactive_1h", "trial_inactive_24h", + "trial_channel_unsubscribed", "expired_1d", "expired_2d", "expired_nd", diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b71ef1df..4d07fbc6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -14,6 +14,7 @@ "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", + "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "CHECK_STATUS_BUTTON": "📊 Check status", "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", "CONFIRM": "✅ Confirm", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 5824f49d..19018adc 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -103,6 +103,7 @@ "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", + "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "CHECK_STATUS_BUTTON": "📊 Проверить статус", "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", "CONFIRM": "✅ Подтвердить", diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a87638c3..873385a9 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -18,6 +18,7 @@ from app.database.crud.discount_offer import ( upsert_discount_offer, ) from app.database.crud.notification import ( + clear_notification_by_type, notification_sent, record_notification, ) @@ -472,6 +473,10 @@ class MonitoringService: try: now = datetime.utcnow() + notifications_allowed = ( + NotificationSettingsService.are_notifications_globally_enabled() + and NotificationSettingsService.is_trial_channel_unsubscribed_enabled() + ) result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -550,6 +555,22 @@ class MonitoringService: user.remnawave_uuid, api_error, ) + + if notifications_allowed: + if not await notification_sent( + db, + user.id, + subscription.id, + "trial_channel_unsubscribed", + ): + sent = await self._send_trial_channel_unsubscribed_notification(user) + if sent: + await record_notification( + db, + user.id, + subscription.id, + "trial_channel_unsubscribed", + ) elif subscription.status == SubscriptionStatus.DISABLED.value and is_member: subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = datetime.utcnow() @@ -575,6 +596,12 @@ class MonitoringService: api_error, ) + await clear_notification_by_type( + db, + subscription.id, + "trial_channel_unsubscribed", + ) + if disabled_count or restored_count: await self._log_monitoring_event( db, @@ -1040,6 +1067,69 @@ class MonitoringService: ) return False + async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "TRIAL_CHANNEL_UNSUBSCRIBED", + ( + "🚫 Доступ приостановлен\n\n" + "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n" + "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ." + ), + ) + + check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался") + message = template.format(check_button=check_button) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + buttons = [] + if settings.CHANNEL_LINK: + buttons.append( + [ + InlineKeyboardButton( + text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), + url=settings.CHANNEL_LINK, + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text=check_button, + callback_data="sub_channel_check", + ) + ] + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await self._send_message_with_logo( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except (TelegramForbiddenError, TelegramBadRequest) as exc: + if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"): + return True + logger.error( + "Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s", + user.telegram_id, + exc, + ) + return False + except Exception as error: + logger.error( + "Ошибка отправки уведомления об отписке от канала пользователю %s: %s", + user.telegram_id, + error, + ) + return False + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py index 959bf93e..457b1c00 100644 --- a/app/services/notification_settings_service.py +++ b/app/services/notification_settings_service.py @@ -20,6 +20,7 @@ class NotificationSettingsService: _DEFAULTS: Dict[str, Dict[str, Any]] = { "trial_inactive_1h": {"enabled": True}, "trial_inactive_24h": {"enabled": True}, + "trial_channel_unsubscribed": {"enabled": True}, "expired_1d": {"enabled": True}, "expired_second_wave": { "enabled": True, @@ -138,6 +139,14 @@ class NotificationSettingsService: def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: return cls.set_enabled("trial_inactive_24h", enabled) + @classmethod + def is_trial_channel_unsubscribed_enabled(cls) -> bool: + return cls.is_enabled("trial_channel_unsubscribed") + + @classmethod + def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_channel_unsubscribed", enabled) + # Expired subscription notifications @classmethod def is_expired_1d_enabled(cls) -> bool: diff --git a/locales/en.json b/locales/en.json index b4efddaf..8d289061 100644 --- a/locales/en.json +++ b/locales/en.json @@ -15,6 +15,7 @@ "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!", "CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing", + "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "CHECK_STATUS_BUTTON": "📊 Check status", "CHOOSE_ANOTHER_DEVICE": "📱 Choose another device", "CONFIRM": "✅ Confirm", diff --git a/locales/ru.json b/locales/ru.json index 0692f952..5f0bdc0b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -187,6 +187,7 @@ "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", "CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!", "CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку", + "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "CHECK_STATUS_BUTTON": "📊 Проверить статус", "CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство", "CONFIRM": "✅ Подтвердить", From 453bb75b7ff1f39ef62bf019ede8e949a3a897f7 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 22:15:57 +0300 Subject: [PATCH 10/24] Add missing admin translations for Russian and English --- app/localization/locales/en.json | 140 ++++++++++++++++++++++++++++++- app/localization/locales/ru.json | 138 +++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 4 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 4d07fbc6..720b26f5 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -427,6 +427,142 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." - + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", + "ADMIN_REPORTS": "📊 Reports", + "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", + "ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:", + "ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:", + "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", + "ATTACHMENTS_SENT": "✅ Attachments sent.", + "BACK_TO_MENU": "🏠 Back to menu", + "BACK_TO_OPEN_TICKETS": "🔴 Open tickets", + "BACK_TO_SUPPORT": "⬅️ Back to support", + "BACK_TO_TICKETS": "⬅️ Back to tickets", + "BALANCE_TOPUP": "💳 Top up balance", + "BLOCK_BY_TIME": "⏳ Temporary block", + "BLOCK_FOREVER": "🚫 Block permanently", + "CAMPAIGN_EXISTING_USERL": "ℹ️ This promotional link is available to new users only.", + "CANCEL_REPLY": "❌ Cancel reply", + "CANCEL_TICKET_CREATION": "❌ Cancel ticket creation", + "CLOSED_TICKETS": "🟢 Closed", + "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", + "CLOSED_TICKETS_TITLE": "🟢 Closed tickets:", + "CLOSE_NOTIFICATION": "❌ Close notification", + "CLOSE_TICKET": "🔒 Close ticket", + "CONTACT_SUPPORT_BUTTON": "💬 Contact support", + "CREATE_TICKET_BUTTON": "🎫 Create ticket", + "DELETE_MESSAGE": "🗑 Delete", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", + "LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.", + "MARK_AS_ANSWERED": "✅ Mark as answered", + "MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Mulen Pay payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.", + "MY_TICKETS_BUTTON": "📋 My tickets", + "MY_TICKETS_TITLE": "📋 Your tickets:", + "NOTIFICATION_CLOSED": "Notification closed.", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NO_ATTACHMENTS": "No attachments.", + "NO_CLOSED_TICKETS": "There are no closed tickets yet.", + "NO_TICKETS": "You don't have any tickets yet.", + "NO_TICKETS_ADMIN": "No tickets to display.", + "OPEN_TICKETS": "🔴 Open", + "OPEN_TICKETS_HEADER": "🔴 Open tickets", + "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", + "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.", + "PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)", + "PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", + "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", + "REPLY_TO_TICKET": "💬 Reply", + "REPORT_CLOSE": "❌ Close", + "REPORT_CLOSED": "✅ Report closed.", + "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", + "SENDING_ATTACHMENTS": "📎 Sending attachments...", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "SUBSCRIPTION_EXTEND": "💎 Extend subscription", + "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Open link in Happ", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Connect via Happ", + "SUPPORT_BUTTON": "🆘 Support", + "TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.", + "TICKET_ATTACHMENTS": "📎 Attachments", + "TICKET_CLOSED": "✅ Ticket closed.", + "TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.", + "TICKET_CLOSE_ERROR": "❌ Error closing ticket.", + "TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.", + "TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", + "TICKET_CREATION_CANCELLED": "Ticket creation cancelled.", + "TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.", + "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", + "TICKET_MESSAGE_INPUT": "Now describe your problem or question:", + "TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:", + "TICKET_NOT_FOUND": "Ticket not found.", + "TICKET_PRIORITY_HIGH": "🟠 High", + "TICKET_PRIORITY_LOW": "🟢 Low", + "TICKET_PRIORITY_NORMAL": "🟡 Normal", + "TICKET_PRIORITY_SELECT": "Select ticket priority:", + "TICKET_PRIORITY_URGENT": "🔴 Urgent", + "TICKET_REPLY_CANCELLED": "Reply cancelled.", + "TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.", + "TICKET_REPLY_INPUT": "Enter your reply:", + "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", + "TICKET_REPLY_SENT": "✅ Your reply has been sent!", + "TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:", + "TICKET_STATUS_ANSWERED": "Answered", + "TICKET_STATUS_CLOSED": "Closed", + "TICKET_STATUS_OPEN": "Open", + "TICKET_STATUS_PENDING": "Pending", + "TICKET_TITLE_INPUT": "Enter ticket title:", + "TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:", + "TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:", + "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "UNBLOCK": "✅ Unblock", + "USER_BLOCKED_FOREVER": "You are blocked from contacting support.", + "USER_BLOCKED_UNTIL": "You are blocked until {time}", + "VIEW_CLOSED_TICKETS": "🟢 Closed tickets", + "VIEW_TICKET": "👁️ View ticket" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 19018adc..b098b064 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -429,6 +429,140 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." - + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства", + "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам", + "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы", + "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик", + "ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:", + "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»", + "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.", + "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", + "ADMIN_REPORTS": "📊 Отчеты", + "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", + "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", + "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", + "ATTACHMENTS_SENT": "✅ Вложения отправлены.", + "BACK_TO_MENU": "🏠 В главное меню", + "BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты", + "BACK_TO_SUPPORT": "⬅️ К поддержке", + "BACK_TO_TICKETS": "⬅️ К тикетам", + "BALANCE_TOPUP": "💳 Пополнить баланс", + "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "BLOCK_FOREVER": "🚫 Заблокировать", + "CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + "CANCEL_REPLY": "❌ Отменить ответ", + "CANCEL_TICKET_CREATION": "❌ Отменить создание тикета", + "CLOSED_TICKETS": "🟢 Закрытые", + "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", + "CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:", + "CLOSE_NOTIFICATION": "❌ Закрыть уведомление", + "CLOSE_TICKET": "🔒 Закрыть тикет", + "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", + "CREATE_TICKET_BUTTON": "🎫 Создать тикет", + "DELETE_MESSAGE": "🗑 Удалить", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", + "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", + "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", + "MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + "MULENPAY_PAYMENT_INSTRUCTIONS": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay", + "MULENPAY_TOPUP_PROMPT": "💳 Оплата через Mulen Pay\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.", + "MY_TICKETS_BUTTON": "📋 Мои тикеты", + "MY_TICKETS_TITLE": "📋 Ваши тикеты:", + "NOTIFICATION_CLOSED": "Уведомление закрыто.", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NO_ATTACHMENTS": "Вложений нет.", + "NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.", + "NO_TICKETS": "У вас пока нет тикетов.", + "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", + "OPEN_TICKETS": "🔴 Открытые", + "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", + "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", + "PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)", + "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", + "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", + "REPLY_TO_TICKET": "💬 Ответить", + "REPORT_CLOSE": "❌ Закрыть", + "REPORT_CLOSED": "✅ Отчет закрыт.", + "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", + "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", + "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", + "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + "SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", + "SUBSCRIPTION_HAPP_OPEN_LINK": "🔓 Открыть ссылку в Happ", + "SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 Подключение через Happ", + "SUPPORT_BUTTON": "🆘 Поддержка", + "TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.", + "TICKET_ATTACHMENTS": "📎 Вложения", + "TICKET_CLOSED": "✅ Тикет закрыт.", + "TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.", + "TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.", + "TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.", + "TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", + "TICKET_CREATION_CANCELLED": "Создание тикета отменено.", + "TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.", + "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", + "TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:", + "TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:", + "TICKET_NOT_FOUND": "Тикет не найден.", + "TICKET_PRIORITY_HIGH": "🟠 Высокий", + "TICKET_PRIORITY_LOW": "🟢 Низкий", + "TICKET_PRIORITY_NORMAL": "🟡 Обычный", + "TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:", + "TICKET_PRIORITY_URGENT": "🔴 Срочный", + "TICKET_REPLY_CANCELLED": "Ответ отменен.", + "TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.", + "TICKET_REPLY_INPUT": "Введите ваш ответ:", + "TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:", + "TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!", + "TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_STATUS_ANSWERED": "Отвечен", + "TICKET_STATUS_CLOSED": "Закрыт", + "TICKET_STATUS_OPEN": "Открыт", + "TICKET_STATUS_PENDING": "В ожидании", + "TICKET_TITLE_INPUT": "Введите заголовок тикета:", + "TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:", + "TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:", + "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "UNBLOCK": "✅ Разблокировать", + "USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.", + "USER_BLOCKED_UNTIL": "Вы заблокированы до {time}", + "VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты", + "VIEW_TICKET": "👁️ Посмотреть тикет" } From db5551df23cc1cb12ef3cf48d63a1a7366f18c13 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 23:42:19 +0300 Subject: [PATCH 11/24] Add customizable PayPalych payment buttons --- app/config.py | 10 ++ app/handlers/admin/bot_configuration.py | 82 +++++++--- app/handlers/balance.py | 192 +++++++++++++++++++----- app/services/payment_service.py | 60 +++++++- locales/en.json | 8 +- locales/ru.json | 8 +- 6 files changed, 297 insertions(+), 63 deletions(-) diff --git a/app/config.py b/app/config.py index 286434d3..a2cbaf80 100644 --- a/app/config.py +++ b/app/config.py @@ -208,6 +208,8 @@ class Settings(BaseSettings): PAL24_MIN_AMOUNT_KOPEKS: int = 10000 PAL24_MAX_AMOUNT_KOPEKS: int = 100000000 PAL24_REQUEST_TIMEOUT: int = 30 + PAL24_SBP_BUTTON_TEXT: Optional[str] = None + PAL24_CARD_BUTTON_TEXT: Optional[str] = None CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" @@ -399,6 +401,14 @@ class Settings(BaseSettings): "password": self.REMNAWAVE_PASSWORD, "auth_type": self.REMNAWAVE_AUTH_TYPE } + + def get_pal24_sbp_button_text(self, fallback: str) -> str: + value = (self.PAL24_SBP_BUTTON_TEXT or "").strip() + return value or fallback + + def get_pal24_card_button_text(self, fallback: str) -> str: + value = (self.PAL24_CARD_BUTTON_TEXT or "").strip() + return value or fallback def get_remnawave_user_delete_mode(self) -> str: """Возвращает режим удаления пользователей: 'delete' или 'disable'""" diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 6e0fadfc..b129ea68 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -851,33 +851,79 @@ async def test_payment_provider( language=language or "ru", ) - if not payment_result or not payment_result.get("link_url") and not payment_result.get("link_page_url"): + if not payment_result: await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True) await _refresh_markup() return - payment_url = payment_result.get("link_page_url") or payment_result.get("link_url") + sbp_url = ( + payment_result.get("sbp_url") + or payment_result.get("transfer_url") + or payment_result.get("link_url") + ) + card_url = payment_result.get("card_url") + fallback_url = payment_result.get("link_page_url") or payment_result.get("link_url") + + if not (sbp_url or card_url or fallback_url): + await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True) + await _refresh_markup() + return + + if not sbp_url: + sbp_url = fallback_url + + default_sbp_text = texts.t( + "PAL24_SBP_PAY_BUTTON", + "🏦 Оплатить через PayPalych (СБП)", + ) + sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) + + default_card_text = texts.t( + "PAL24_CARD_PAY_BUTTON", + "💳 Оплатить банковской картой (PayPalych)", + ) + card_button_text = settings.get_pal24_card_button_text(default_card_text) + + pay_rows: list[list[types.InlineKeyboardButton]] = [] + if sbp_url: + pay_rows.append([ + types.InlineKeyboardButton( + text=sbp_button_text, + url=sbp_url, + ) + ]) + + if card_url and card_url != sbp_url: + pay_rows.append([ + types.InlineKeyboardButton( + text=card_button_text, + url=card_url, + ) + ]) + + if not pay_rows and fallback_url: + pay_rows.append([ + types.InlineKeyboardButton( + text=sbp_button_text, + url=fallback_url, + ) + ]) + message_text = ( "🧪 Тестовый платеж PayPalych\n\n" f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" f"🆔 Bill ID: {payment_result['bill_id']}" ) - reply_markup = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🏦 Перейти к оплате (СБП)", - url=payment_url, - ) - ], - [ - types.InlineKeyboardButton( - text="📊 Проверить статус", - callback_data=f"check_pal24_{payment_result['local_payment_id']}", - ) - ], - ] - ) + keyboard_rows = pay_rows + [ + [ + types.InlineKeyboardButton( + text="📊 Проверить статус", + callback_data=f"check_pal24_{payment_result['local_payment_id']}", + ) + ], + ] + + reply_markup = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") await callback.answer("✅ Ссылка на платеж PayPalych отправлена", show_alert=True) await _refresh_markup() diff --git a/app/handlers/balance.py b/app/handlers/balance.py index e6f0b832..adcabebd 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -1,3 +1,4 @@ +import html import logging from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext @@ -1002,9 +1003,7 @@ async def process_pal24_payment_amount( language=db_user.language, ) - if not payment_result or not ( - payment_result.get("link_url") or payment_result.get("link_page_url") - ): + if not payment_result: await message.answer( texts.t( "PAL24_PAYMENT_ERROR", @@ -1014,49 +1013,146 @@ async def process_pal24_payment_amount( await state.clear() return - payment_url = ( + sbp_url = ( + payment_result.get("sbp_url") + or payment_result.get("transfer_url") + ) + card_url = payment_result.get("card_url") + fallback_url = ( payment_result.get("link_page_url") or payment_result.get("link_url") ) + + if not (sbp_url or card_url or fallback_url): + await message.answer( + texts.t( + "PAL24_PAYMENT_ERROR", + "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + ) + ) + await state.clear() + return + + if not sbp_url: + sbp_url = fallback_url + bill_id = payment_result.get("bill_id") local_payment_id = payment_result.get("local_payment_id") - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("PAL24_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)"), - url=payment_url, - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_pal24_{local_payment_id}", - ) - ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], - ] + pay_buttons: list[list[types.InlineKeyboardButton]] = [] + steps: list[str] = [] + step_counter = 1 + + default_sbp_text = texts.t( + "PAL24_SBP_PAY_BUTTON", + "🏦 Оплатить через PayPalych (СБП)", ) + sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) + + if sbp_url: + pay_buttons.append( + [ + types.InlineKeyboardButton( + text=sbp_button_text, + url=sbp_url, + ) + ] + ) + steps.append( + texts.t( + "PAL24_INSTRUCTION_BUTTON", + "{step}. Нажмите кнопку «{button}»", + ).format(step=step_counter, button=html.escape(sbp_button_text)) + ) + step_counter += 1 + + default_card_text = texts.t( + "PAL24_CARD_PAY_BUTTON", + "💳 Оплатить банковской картой (PayPalych)", + ) + card_button_text = settings.get_pal24_card_button_text(default_card_text) + + if card_url and card_url != sbp_url: + pay_buttons.append( + [ + types.InlineKeyboardButton( + text=card_button_text, + url=card_url, + ) + ] + ) + steps.append( + texts.t( + "PAL24_INSTRUCTION_BUTTON", + "{step}. Нажмите кнопку «{button}»", + ).format(step=step_counter, button=html.escape(card_button_text)) + ) + step_counter += 1 + + if not pay_buttons and fallback_url: + pay_buttons.append( + [ + types.InlineKeyboardButton( + text=sbp_button_text, + url=fallback_url, + ) + ] + ) + steps.append( + texts.t( + "PAL24_INSTRUCTION_BUTTON", + "{step}. Нажмите кнопку «{button}»", + ).format(step=step_counter, button=html.escape(sbp_button_text)) + ) + step_counter += 1 + + follow_template = texts.t( + "PAL24_INSTRUCTION_FOLLOW", + "{step}. Следуйте подсказкам платёжной системы", + ) + steps.append(follow_template.format(step=step_counter)) + step_counter += 1 + + confirm_template = texts.t( + "PAL24_INSTRUCTION_CONFIRM", + "{step}. Подтвердите перевод", + ) + steps.append(confirm_template.format(step=step_counter)) + step_counter += 1 + + success_template = texts.t( + "PAL24_INSTRUCTION_COMPLETE", + "{step}. Средства зачислятся автоматически", + ) + steps.append(success_template.format(step=step_counter)) message_template = texts.t( "PAL24_PAYMENT_INSTRUCTIONS", ( - "🏦 Оплата через PayPalych (СБП)\n\n" + "🏦 Оплата через PayPalych\n\n" "💰 Сумма: {amount}\n" "🆔 ID счета: {bill_id}\n\n" - "📱 Инструкция:\n" - "1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n" - "2. Следуйте подсказкам платежной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" + "📱 Инструкция:\n{steps}\n\n" "❓ Если возникнут проблемы, обратитесь в {support}" ), ) + keyboard_rows = pay_buttons + [ + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_pal24_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ] + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + message_text = message_template.format( amount=settings.format_price(amount_kopeks), bill_id=bill_id, + steps="\n".join(steps), support=settings.get_support_contact_display_html(), ) @@ -1231,28 +1327,48 @@ async def check_pal24_payment_status( emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) - payment_link = payment.link_page_url or payment.link_url + metadata = payment.metadata_json or {} + links_meta = metadata.get("links") if isinstance(metadata, dict) else None + if not isinstance(links_meta, dict): + links_meta = {} + + sbp_link = links_meta.get("sbp") or payment.link_url + card_link = links_meta.get("card") + + if not card_link and payment.link_page_url and payment.link_page_url != sbp_link: + card_link = payment.link_page_url message_lines = [ - "🏦 Статус платежа PayPalych (СБП):\n\n", - f"🆔 ID счета: {payment.bill_id}\n", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", - f"📊 Статус: {emoji} {status_text}\n", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + "🏦 Статус платежа PayPalych:", + "", + f"🆔 ID счета: {payment.bill_id}", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", + f"📊 Статус: {emoji} {status_text}", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}", ] if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") + message_lines.append("") + message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.") elif payment.status in {"NEW", "PROCESS"}: - message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.") - if payment_link: - message_lines.append(f"\n🔗 Ссылка на оплату: {payment_link}") + message_lines.append("") + message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.") + if sbp_link: + message_lines.append("") + message_lines.append(f"🏦 СБП: {sbp_link}") + if card_link and card_link != sbp_link: + message_lines.append(f"💳 Банковская карта: {card_link}") elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}: + message_lines.append("") message_lines.append( - f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" ) - await callback.answer("".join(message_lines), show_alert=True) + await callback.answer() + await callback.message.answer( + "\n".join(message_lines), + disable_web_page_preview=True, + ) except Exception as e: logger.error(f"Ошибка проверки статуса PayPalych: {e}") diff --git a/app/services/payment_service.py b/app/services/payment_service.py index c615e8cc..6523375d 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -872,9 +872,55 @@ class PaymentService: logger.error("Pal24 не вернул bill_id: %s", response) return None - link_url = response.get("link_url") - link_page_url = response.get("link_page_url") - primary_link = link_page_url or link_url + def _pick_url(*keys: str) -> Optional[str]: + for key in keys: + value = response.get(key) + if value: + return str(value) + return None + + transfer_url = _pick_url( + "transfer_url", + "transferUrl", + "transfer_link", + "transferLink", + "transfer", + "sbp_url", + "sbpUrl", + "sbp_link", + "sbpLink", + ) + card_url = _pick_url( + "link_url", + "linkUrl", + "link", + "card_url", + "cardUrl", + "card_link", + "cardLink", + "payment_url", + "paymentUrl", + "url", + ) + link_page_url = _pick_url( + "link_page_url", + "linkPageUrl", + "page_url", + "pageUrl", + ) + + primary_link = transfer_url or link_page_url or card_url + secondary_link = link_page_url or card_url or transfer_url + + metadata_links = { + key: value + for key, value in { + "sbp": transfer_url, + "card": card_url, + "page": link_page_url, + }.items() + if value + } payment = await create_pal24_payment( db, @@ -887,11 +933,12 @@ class PaymentService: type_=response.get("type", "normal"), currency=response.get("currency", "RUB"), link_url=primary_link, - link_page_url=link_page_url or link_url, + link_page_url=secondary_link, ttl=ttl_seconds, metadata={ "raw_response": response, "language": language, + **({"links": metadata_links} if metadata_links else {}), }, ) @@ -899,9 +946,12 @@ class PaymentService: "bill_id": bill_id, "order_id": order_id, "link_url": primary_link, - "link_page_url": link_page_url or link_url, + "link_page_url": secondary_link, "local_payment_id": payment.id, "amount_kopeks": amount_kopeks, + "sbp_url": transfer_url or primary_link, + "card_url": card_url, + "transfer_url": transfer_url, } logger.info( diff --git a/locales/en.json b/locales/en.json index 8d289061..3b95753a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -86,7 +86,13 @@ "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.", "PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.", "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", - "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych (SBP) payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", + "PAL24_CARD_PAY_BUTTON": "💳 Pay with a bank card (PayPalych)", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\n{steps}\n\n❓ Need help? Contact {support}", + "PAL24_INSTRUCTION_BUTTON": "{step}. Press “{button}”", + "PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions", + "PAL24_INSTRUCTION_CONFIRM": "{step}. Confirm the transfer", + "PAL24_INSTRUCTION_COMPLETE": "{step}. The funds will be credited automatically", "PENDING_CANCEL_BUTTON": "⌛ Cancel", "POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀", "REFERRAL_ANALYTICS_BUTTON": "📊 Analytics", diff --git a/locales/ru.json b/locales/ru.json index 5f0bdc0b..c36d4516 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -276,7 +276,13 @@ "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", - "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych (СБП)\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", + "PAL24_CARD_PAY_BUTTON": "💳 Оплатить банковской картой (PayPalych)", + "PAL24_PAYMENT_INSTRUCTIONS": "🏦 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\n{steps}\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_INSTRUCTION_BUTTON": "{step}. Нажмите кнопку «{button}»", + "PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы", + "PAL24_INSTRUCTION_CONFIRM": "{step}. Подтвердите перевод", + "PAL24_INSTRUCTION_COMPLETE": "{step}. Средства зачислятся автоматически", "PENDING_CANCEL_BUTTON": "⌛ Отмена", "PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}", "PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}", From 07e4c07d6edae8ab2b449f5f6d9a25d2393dc23f Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 23:47:06 +0300 Subject: [PATCH 12/24] Add admin panel translations for ru/en locales --- app/handlers/admin/messages.py | 16 +- app/handlers/admin/monitoring.py | 3 +- app/keyboards/admin.py | 1289 ++++++++++++++++++++++-------- locales/en.json | 203 ++++- locales/ru.json | 203 ++++- 5 files changed, 1378 insertions(+), 336 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index cc79292b..3f58fcd4 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -21,8 +21,8 @@ from app.keyboards.admin import ( get_custom_criteria_keyboard, get_broadcast_history_keyboard, get_admin_pagination_keyboard, get_broadcast_media_keyboard, get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media, - BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, - BROADCAST_BUTTON_LABELS + BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, + get_broadcast_button_config, get_broadcast_button_labels ) from app.localization.texts import get_texts from app.database.crud.user import get_users_list @@ -31,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) -BUTTON_CONFIG = BROADCAST_BUTTONS BUTTON_ROWS = BROADCAST_BUTTON_ROWS DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS -BUTTON_LABELS = BROADCAST_BUTTON_LABELS def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup: @@ -45,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language) -def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]: +def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]: selected_buttons = selected_buttons or [] keyboard: list[list[types.InlineKeyboardButton]] = [] + button_config_map = get_broadcast_button_config(language) for row in BUTTON_ROWS: row_buttons: list[types.InlineKeyboardButton] = [] for button_key in row: if button_key not in selected_buttons: continue - button_config = BUTTON_CONFIG[button_key] + button_config = button_config_map[button_key] row_buttons.append( types.InlineKeyboardButton( text=button_config["text"], @@ -628,7 +627,8 @@ async def confirm_button_selection( media_info = f"\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}" ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row] - selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons] + button_labels = get_broadcast_button_labels(db_user.language) + selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons] if selected_names: buttons_info = f"\n📘 Кнопки: {', '.join(selected_names)}" else: @@ -745,7 +745,7 @@ async def confirm_broadcast( sent_count = 0 failed_count = 0 - broadcast_keyboard = create_broadcast_keyboard(selected_buttons) + broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language) for user in users: try: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 7c8c9371..528288b4 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -404,7 +404,8 @@ async def admin_monitoring_menu(callback: CallbackQuery): 🔧 Выберите действие: """ - keyboard = get_monitoring_keyboard() + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + keyboard = get_monitoring_keyboard(language) await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) break diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 42bb14d3..ee7729af 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -4,16 +4,21 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from app.localization.texts import get_texts +def _t(texts, key: str, default: str) -> str: + """Helper for localized button labels with fallbacks.""" + return texts.t(key, default) + + def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], - [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], - [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], - [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], - [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], - [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"), callback_data="admin_submenu_users")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), callback_data="admin_submenu_promo")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), callback_data="admin_submenu_support")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"), callback_data="admin_submenu_communications")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"), callback_data="admin_submenu_settings")], + [InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"), callback_data="admin_submenu_system")], [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) @@ -30,7 +35,7 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions") ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) @@ -50,7 +55,7 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups") ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) @@ -63,11 +68,17 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], [ - InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"), - InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel") + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"), + callback_data="welcome_text_panel" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_MENU_MESSAGES", "📢 Сообщения в меню"), + callback_data="user_messages_panel" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) @@ -77,16 +88,25 @@ def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMa return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), + callback_data="admin_tickets" + ) ], [ - InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUPPORT_AUDIT", "🧾 Аудит модераторов"), + callback_data="admin_support_audit" + ) ], [ - InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUPPORT_SETTINGS", "🛟 Настройки поддержки"), + callback_data="admin_support_settings" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) @@ -100,7 +120,10 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], [ - InlineKeyboardButton(text="🧩 Конфигурация бота", callback_data="admin_bot_config"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_BOT_CONFIG", "🧩 Конфигурация бота"), + callback_data="admin_bot_config" + ), ], [ InlineKeyboardButton( @@ -110,10 +133,13 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM ], [ InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), - InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SETTINGS_MAINTENANCE", "🔧 Техработы"), + callback_data="maintenance_panel" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) @@ -123,23 +149,51 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"), - InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYSTEM_UPDATES", "📄 Обновления"), + callback_data="admin_updates" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYSTEM_BACKUPS", "🗄️ Бекапы"), + callback_data="backup_panel" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYSTEM_LOGS", "🧾 Логи"), + callback_data="admin_system_logs" + ) ], - [InlineKeyboardButton(text="🧾 Логи", callback_data="admin_system_logs")], [InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") ] ]) def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")], - [InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")], - [InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_REPORTS_PREVIOUS_DAY", "📆 За вчера"), + callback_data="admin_reports_daily" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_REPORTS_LAST_WEEK", "🗓️ За неделю"), + callback_data="admin_reports_weekly" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_REPORTS_LAST_MONTH", "📅 За месяц"), + callback_data="admin_reports_monthly" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")] ]) @@ -152,80 +206,139 @@ def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"), - InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_ALL", "👥 Все пользователи"), + callback_data="admin_users_list" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_SEARCH", "🔍 Поиск"), + callback_data="admin_users_search" + ) ], [ - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"), - InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive") + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_users_stats"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_INACTIVE", "🗑️ Неактивные"), + callback_data="admin_users_inactive" + ) ], [ - InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTERS", "⚙️ Фильтры"), + callback_data="admin_users_filters" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users") ] ]) def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_BALANCE", "💰 По балансу"), + callback_data="admin_users_balance_filter" + ) ], [ - InlineKeyboardButton(text="📶 По трафику", callback_data="admin_users_traffic_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_TRAFFIC", "📶 По трафику"), + callback_data="admin_users_traffic_filter" + ) ], [ - InlineKeyboardButton(text="🕒 По активности", callback_data="admin_users_activity_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_ACTIVITY", "🕒 По активности"), + callback_data="admin_users_activity_filter" + ) ], [ - InlineKeyboardButton(text="💳 По сумме трат", callback_data="admin_users_spending_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_SPENDING", "💳 По сумме трат"), + callback_data="admin_users_spending_filter" + ) ], [ - InlineKeyboardButton(text="🛒 По количеству покупок", callback_data="admin_users_purchases_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_PURCHASES", "🛒 По количеству покупок"), + callback_data="admin_users_purchases_filter" + ) ], [ - InlineKeyboardButton(text="📢 По кампании", callback_data="admin_users_campaign_filter") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"), + callback_data="admin_users_campaign_filter" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_users") ] ]) def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"), - InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUBSCRIPTIONS_ALL", "📱 Все подписки"), + callback_data="admin_subs_list" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUBSCRIPTIONS_EXPIRING", "⏰ Истекающие"), + callback_data="admin_subs_expiring" + ) ], [ - InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"), - InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUBSCRIPTIONS_PRICING", "⚙️ Настройки цен"), + callback_data="admin_subs_pricing" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SUBSCRIPTIONS_COUNTRIES", "🌍 Управление странами"), + callback_data="admin_subs_countries" + ) ], [ - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats") + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_subs_stats") ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users") ] ]) def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"), - InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODES_ALL", "🎫 Все промокоды"), + callback_data="admin_promo_list" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"), + callback_data="admin_promo_create" + ) ], [ - InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODES_GENERAL_STATS", "📊 Общая статистика"), + callback_data="admin_promo_general_stats" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") ] ]) @@ -235,11 +348,20 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"), - InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CAMPAIGNS_LIST", "📋 Список кампаний"), + callback_data="admin_campaigns_list" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CAMPAIGNS_CREATE", "➕ Создать"), + callback_data="admin_campaigns_create" + ) ], [ - InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CAMPAIGNS_GENERAL_STATS", "📊 Общая статистика"), + callback_data="admin_campaigns_stats" + ) ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") @@ -250,13 +372,18 @@ def get_admin_campaigns_keyboard(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 "🟢 Включить" + texts = get_texts(language) + status_text = ( + _t(texts, "ADMIN_CAMPAIGN_DISABLE", "🔴 Выключить") + if is_active + else _t(texts, "ADMIN_CAMPAIGN_ENABLE", "🟢 Включить") + ) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="📊 Статистика", + text=_t(texts, "ADMIN_CAMPAIGN_STATS", "📊 Статистика"), callback_data=f"admin_campaign_stats_{campaign_id}", ), InlineKeyboardButton( @@ -266,19 +393,20 @@ def get_campaign_management_keyboard( ], [ InlineKeyboardButton( - text="✏️ Редактировать", + text=_t(texts, "ADMIN_CAMPAIGN_EDIT", "✏️ Редактировать"), callback_data=f"admin_campaign_edit_{campaign_id}", ) ], [ InlineKeyboardButton( - text="🗑️ Удалить", + text=_t(texts, "ADMIN_CAMPAIGN_DELETE", "🗑️ Удалить"), callback_data=f"admin_campaign_delete_{campaign_id}", ) ], [ InlineKeyboardButton( - text="⬅️ К списку", callback_data="admin_campaigns_list" + text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), + callback_data="admin_campaigns_list" ) ], ] @@ -296,11 +424,11 @@ def get_campaign_edit_keyboard( keyboard: List[List[InlineKeyboardButton]] = [ [ InlineKeyboardButton( - text="✏️ Название", + text=_t(texts, "ADMIN_CAMPAIGN_EDIT_NAME", "✏️ Название"), callback_data=f"admin_campaign_edit_name_{campaign_id}", ), InlineKeyboardButton( - text="🔗 Параметр", + text=_t(texts, "ADMIN_CAMPAIGN_EDIT_START", "🔗 Параметр"), callback_data=f"admin_campaign_edit_start_{campaign_id}", ), ] @@ -310,7 +438,7 @@ def get_campaign_edit_keyboard( keyboard.append( [ InlineKeyboardButton( - text="💰 Бонус на баланс", + text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"), callback_data=f"admin_campaign_edit_balance_{campaign_id}", ) ] @@ -320,21 +448,21 @@ def get_campaign_edit_keyboard( [ [ InlineKeyboardButton( - text="📅 Длительность", + text=_t(texts, "ADMIN_CAMPAIGN_DURATION", "📅 Длительность"), callback_data=f"admin_campaign_edit_sub_days_{campaign_id}", ), InlineKeyboardButton( - text="🌐 Трафик", + text=_t(texts, "ADMIN_CAMPAIGN_TRAFFIC", "🌐 Трафик"), callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}", ), ], [ InlineKeyboardButton( - text="📱 Устройства", + text=_t(texts, "ADMIN_CAMPAIGN_DEVICES", "📱 Устройства"), callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}", ), InlineKeyboardButton( - text="🌍 Серверы", + text=_t(texts, "ADMIN_CAMPAIGN_SERVERS", "🌍 Серверы"), callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}", ), ], @@ -357,8 +485,14 @@ def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMark return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"), - InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"), + callback_data="campaign_bonus_balance" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION", "📱 Подписка"), + callback_data="campaign_bonus_subscription" + ) ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") @@ -367,90 +501,169 @@ def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMark def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"), - InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_EDIT", "✏️ Редактировать"), + callback_data=f"promo_edit_{promo_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_TOGGLE", "🔄 Статус"), + callback_data=f"promo_toggle_{promo_id}" + ) ], [ - InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"), - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_STATS", "📊 Статистика"), + callback_data=f"promo_stats_{promo_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_DELETE", "🗑️ Удалить"), + callback_data=f"promo_delete_{promo_id}" + ) ], [ - InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list") + InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), callback_data="admin_promo_list") ] ]) def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"), - InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MESSAGES_ALL_USERS", "📨 Всем пользователям"), + callback_data="admin_msg_all" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MESSAGES_BY_SUBSCRIPTIONS", "🎯 По подпискам"), + callback_data="admin_msg_by_sub" + ) ], [ - InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"), - InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MESSAGES_BY_CRITERIA", "🔍 По критериям"), + callback_data="admin_msg_custom" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MESSAGES_HISTORY", "📋 История"), + callback_data="admin_msg_history" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") ] ]) def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"), - InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), + callback_data="admin_mon_start" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STOP", "⏸️ Остановить"), + callback_data="admin_mon_stop" + ) ], [ - InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"), - InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STATUS", "📊 Статус"), + callback_data="admin_mon_status" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), + callback_data="admin_mon_logs" + ) ], [ - InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_SETTINGS_BUTTON", "⚙️ Настройки"), + callback_data="admin_mon_settings" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") ] ]) def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"), - InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes") + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_SYSTEM_STATS", "📊 Системная статистика"), + callback_data="admin_rw_system" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_NODES", "🖥️ Управление нодами"), + callback_data="admin_rw_nodes" + ) ], [ - InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"), - InlineKeyboardButton(text="🌐 Управление сквадами", callback_data="admin_rw_squads") + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_SYNC", "🔄 Синхронизация"), + callback_data="admin_rw_sync" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_SQUADS", "🌐 Управление сквадами"), + callback_data="admin_rw_squads" + ) ], [ - InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic") + InlineKeyboardButton( + text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"), + callback_data="admin_rw_traffic" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") ] ]) def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"), - InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_USERS", "👥 Пользователи"), + callback_data="admin_stats_users" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_SUBSCRIPTIONS", "📱 Подписки"), + callback_data="admin_stats_subs" + ) ], [ - InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"), - InlineKeyboardButton(text="🤝 Партнерка", callback_data="admin_stats_referrals") + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_REVENUE", "💰 Доходы"), + callback_data="admin_stats_revenue" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_REFERRALS", "🤝 Партнерка"), + callback_data="admin_stats_referrals" + ) ], [ - InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary") + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_SUMMARY", "📊 Общая сводка"), + callback_data="admin_stats_summary" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") ] ]) @@ -460,8 +673,14 @@ 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=_t(texts, "ADMIN_USER_BALANCE", "💰 Баланс"), + callback_data=f"admin_user_balance_{user_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_SUBSCRIPTION_SETTINGS", "📱 Подписка и настройки"), + callback_data=f"admin_user_subscription_{user_id}" + ) ], [ InlineKeyboardButton( @@ -470,30 +689,51 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = ) ], [ - InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), + callback_data=f"admin_user_statistics_{user_id}" + ) ], [ - InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_TRANSACTIONS", "📋 Транзакции"), + callback_data=f"admin_user_transactions_{user_id}" + ) ] ] - + if user_status == "active": keyboard.append([ - InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"), - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_BLOCK", "🚫 Заблокировать"), + callback_data=f"admin_user_block_{user_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"), + callback_data=f"admin_user_delete_{user_id}" + ) ]) elif user_status == "blocked": keyboard.append([ - InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"), - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_UNBLOCK", "✅ Разблокировать"), + callback_data=f"admin_user_unblock_{user_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"), + callback_data=f"admin_user_delete_{user_id}" + ) ]) elif user_status == "deleted": keyboard.append([ - InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop") + InlineKeyboardButton( + text=_t(texts, "ADMIN_USER_ALREADY_DELETED", "❌ Пользователь удален"), + callback_data="noop" + ) ]) - + keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback) + InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -545,21 +785,33 @@ def get_confirmation_keyboard( def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"), - InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_TYPE_BALANCE", "💰 Баланс"), + callback_data="promo_type_balance" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_TYPE_DAYS", "📅 Дни подписки"), + callback_data="promo_type_days" + ) ], [ - InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"), + callback_data="promo_type_trial" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes") ] ]) def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] for promo in promocodes: @@ -593,65 +845,122 @@ def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, l keyboard.append(pagination_row) keyboard.extend([ - [InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"), + callback_data="admin_promo_create" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes")] ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"), - InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), + callback_data="broadcast_all" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), + callback_data="broadcast_active" + ) ], [ - InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"), - InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), + callback_data="broadcast_trial" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), + callback_data="broadcast_no_sub" + ) ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), - InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), + callback_data="broadcast_expiring" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), + callback_data="broadcast_expired" + ) ], [ - InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"), - InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), + callback_data="broadcast_active_zero" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), + callback_data="broadcast_trial_zero" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] ]) def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"), - InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_TODAY", "📅 Сегодня"), + callback_data="criteria_today" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_WEEK", "📅 За неделю"), + callback_data="criteria_week" + ) ], [ - InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"), - InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_MONTH", "📅 За месяц"), + callback_data="criteria_month" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), + callback_data="criteria_active_today" + ) ], [ - InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"), - InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), + callback_data="criteria_inactive_week" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), + callback_data="criteria_inactive_month" + ) ], [ - InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"), - InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), + callback_data="criteria_referrals" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_PROMOCODES", "🎫 Использовали промокоды"), + callback_data="criteria_promocodes" + ) ], [ - InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), + callback_data="criteria_direct" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] ]) def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] if total_pages > 1: @@ -674,55 +983,116 @@ def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = keyboard.append(pagination_row) keyboard.extend([ - [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), + callback_data="admin_msg_history" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [ - [InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")], - [InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")], - [InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")], [ - InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"), - InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), + callback_data="sync_all_users" + ) ], - [InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"), + callback_data="sync_new_users" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_UPDATE", "📈 Обновить данные"), + callback_data="sync_update_data" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"), + callback_data="sync_validate" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_CLEANUP", "🧹 Очистка"), + callback_data="sync_cleanup" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_RECOMMENDATIONS", "💡 Рекомендации"), + callback_data="sync_recommendations" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")] ] - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [ - [InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")], - [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_CONFIRM", "✅ Подтвердить"), + callback_data=f"confirm_{sync_type}" + ) + ], + [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_rw_sync")] ] - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] - + if has_errors: keyboard.append([ - InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_RETRY", "🔄 Повторить"), + callback_data=f"sync_{sync_type}" + ) ]) - + if sync_type != "all_users": keyboard.append([ - InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), + callback_data="sync_all_users" + ) ]) - + keyboard.extend([ [ - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"), - InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate") + InlineKeyboardButton( + text=_t(texts, "ADMIN_STATS_BUTTON", "📊 Статистика"), + callback_data="admin_rw_system" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"), + callback_data="sync_validate" + ) ], - [InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")], - [InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_BACK", "⬅️ К синхронизации"), + callback_data="admin_rw_sync" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_MAIN", "🏠 В главное меню"), + callback_data="admin_remnawave" + ) + ] ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -730,104 +1100,185 @@ def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"), - InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PERIOD_TODAY", "📅 Сегодня"), + callback_data="period_today" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PERIOD_YESTERDAY", "📅 Вчера"), + callback_data="period_yesterday" + ) ], [ - InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"), - InlineKeyboardButton(text="📅 Месяц", callback_data="period_month") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PERIOD_WEEK", "📅 Неделя"), + callback_data="period_week" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PERIOD_MONTH", "📅 Месяц"), + callback_data="period_month" + ) ], [ - InlineKeyboardButton(text="📅 Все время", callback_data="period_all") + InlineKeyboardButton( + text=_t(texts, "ADMIN_PERIOD_ALL", "📅 Все время"), + callback_data="period_all" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_statistics")] ]) def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"), - InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_NODE_ENABLE", "▶️ Включить"), + callback_data=f"node_enable_{node_uuid}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_NODE_DISABLE", "⏸️ Отключить"), + callback_data=f"node_disable_{node_uuid}" + ) ], [ - InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"), - InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_NODE_RESTART", "🔄 Перезагрузить"), + callback_data=f"node_restart_{node_uuid}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_NODE_STATS", "📊 Статистика"), + callback_data=f"node_stats_{node_uuid}" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_nodes")] ]) def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_ADD_ALL", "👥 Добавить всех пользователей"), + callback_data=f"squad_add_users_{squad_uuid}" + ), ], [ - InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_REMOVE_ALL", "❌ Удалить всех пользователей"), + callback_data=f"squad_remove_users_{squad_uuid}" + ), ], [ - InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"), - InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_EDIT", "✏️ Редактировать"), + callback_data=f"squad_edit_{squad_uuid}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_DELETE", "🗑️ Удалить сквад"), + callback_data=f"squad_delete_{squad_uuid}" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_squads")] ]) def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_EDIT_INBOUNDS", "🔧 Изменить инбаунды"), + callback_data=f"squad_edit_inbounds_{squad_uuid}" + ), ], [ - InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SQUAD_RENAME", "✏️ Переименовать"), + callback_data=f"squad_rename_{squad_uuid}" + ), ], [ - InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_SQUADS", "⬅️ Назад к сквадам"), + callback_data=f"admin_squad_manage_{squad_uuid}" + ) ] ]) -def get_monitoring_keyboard() -> InlineKeyboardMarkup: +def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"), - InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), + callback_data="admin_mon_start" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"), + callback_data="admin_mon_stop" + ) ], [ - InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"), - InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "🔄 Принудительная проверка"), + callback_data="admin_mon_force_check" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), + callback_data="admin_mon_logs" + ) ], [ - InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"), - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"), + callback_data="admin_mon_test_notifications" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"), + callback_data="admin_mon_statistics" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_ADMIN", "⬅️ Назад в админку"), + callback_data="admin_panel" + ) ] ]) -def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup: +def get_monitoring_logs_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), + callback_data="admin_mon_logs" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_CLEAR_OLD", "🗑️ Очистить старые"), + callback_data="admin_mon_clear_logs" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")] ]) def get_monitoring_logs_navigation_keyboard( - current_page: int, + current_page: int, total_pages: int, - has_logs: bool = True + has_logs: bool = True, + language: str = "ru" ) -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] if total_pages > 1: @@ -854,179 +1305,274 @@ def get_monitoring_logs_navigation_keyboard( management_row = [] + refresh_button = InlineKeyboardButton( + text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), + callback_data="admin_mon_logs" + ) + if has_logs: management_row.extend([ - InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs") + refresh_button, + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_CLEAR", "🗑️ Очистить"), + callback_data="admin_mon_clear_logs" + ) ]) else: - management_row.append( - InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs") - ) + management_row.append(refresh_button) keyboard.append(management_row) keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ Назад к мониторингу"), + callback_data="admin_monitoring" + ) ]) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup: +def get_log_detail_keyboard(log_id: int, current_page: int = 1, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( - text="🗑️ Удалить этот лог", + text=_t(texts, "ADMIN_MONITORING_DELETE_LOG", "🗑️ Удалить этот лог"), callback_data=f"admin_mon_delete_log_{log_id}" ) ], [ InlineKeyboardButton( - text="⬅️ К списку логов", + text=_t(texts, "ADMIN_MONITORING_BACK_TO_LOGS", "⬅️ К списку логов"), callback_data=f"admin_mon_logs_page_{current_page}" ) ] ]) -def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup: +def get_monitoring_clear_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"), - InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_CONFIRM_CLEAR", "✅ Да, очистить"), + callback_data="admin_mon_clear_logs_confirm" + ), + InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_mon_logs") ], [ - InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_CLEAR_ALL", "🗑️ Очистить ВСЕ логи"), + callback_data="admin_mon_clear_all_logs" + ) ] ]) def get_monitoring_status_keyboard( is_running: bool, - last_check_ago_minutes: int = 0 + last_check_ago_minutes: int = 0, + language: str = "ru" ) -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] - + control_row = [] if is_running: control_row.extend([ - InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"), - InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"), + callback_data="admin_mon_stop" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_RESTART", "🔄 Перезапустить"), + callback_data="admin_mon_restart" + ) ]) else: control_row.append( - InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), + callback_data="admin_mon_start" + ) ) - + keyboard.append(control_row) - + monitoring_row = [] - + if not is_running or last_check_ago_minutes > 10: monitoring_row.append( InlineKeyboardButton( - text="⚡ Срочная проверка", + text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "⚡ Срочная проверка"), callback_data="admin_mon_force_check" ) ) else: monitoring_row.append( InlineKeyboardButton( - text="🔄 Проверить сейчас", + text=_t(texts, "ADMIN_MONITORING_CHECK_NOW", "🔄 Проверить сейчас"), callback_data="admin_mon_force_check" ) ) - + keyboard.append(monitoring_row) - + info_row = [ - InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics") + InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), callback_data="admin_mon_logs"), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"), + callback_data="admin_mon_statistics" + ) ] keyboard.append(info_row) - + test_row = [ - InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"), + callback_data="admin_mon_test_notifications" + ) ] keyboard.append(test_row) - + keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup: +def get_monitoring_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"), - InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_SET_INTERVAL", "⏱️ Интервал проверки"), + callback_data="admin_mon_set_interval" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_NOTIFICATIONS", "🔔 Уведомления"), + callback_data="admin_mon_toggle_notifications" + ) ], [ - InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"), - InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_AUTOPAY_SETTINGS", "💳 Настройки автооплаты"), + callback_data="admin_mon_autopay_settings" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_AUTO_CLEANUP", "🧹 Автоочистка логов"), + callback_data="admin_mon_auto_cleanup" + ) ], - [ - InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring") - ] + [InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ К мониторингу"), callback_data="admin_monitoring")] ]) -def get_log_type_filter_keyboard() -> InlineKeyboardMarkup: +def get_log_type_filter_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"), - InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_FILTER_SUCCESS", "✅ Успешные"), + callback_data="admin_mon_logs_filter_success" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_FILTER_ERRORS", "❌ Ошибки"), + callback_data="admin_mon_logs_filter_error" + ) ], [ - InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"), - InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay") + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_FILTER_CYCLES", "🔄 Циклы мониторинга"), + callback_data="admin_mon_logs_filter_cycle" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_MONITORING_FILTER_AUTOPAY", "💳 Автооплаты"), + callback_data="admin_mon_logs_filter_autopay" + ) ], [ - InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring") + InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_ALL_LOGS", "📋 Все логи"), callback_data="admin_mon_logs"), + InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring") ] ]) def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - + + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), - InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVERS_LIST", "📋 Список серверов"), + callback_data="admin_servers_list" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVERS_SYNC", "🔄 Синхронизация"), + callback_data="admin_servers_sync" + ) ], [ - InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"), - InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVERS_ADD", "➕ Добавить сервер"), + callback_data="admin_servers_add" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVERS_STATS", "📊 Статистика"), + callback_data="admin_servers_stats" + ) ], - [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_subscriptions")] ]) def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup: - + texts = get_texts(language) + + toggle_text = _t(texts, "ADMIN_SERVER_DISABLE", "❌ Отключить") if is_available else _t(texts, "ADMIN_SERVER_ENABLE", "✅ Включить") + return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"), - InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}") - ], - [ - InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"), - InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}") - ], - [ - InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVER_EDIT_NAME", "✏️ Название"), + callback_data=f"admin_server_edit_name_{server_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVER_EDIT_PRICE", "💰 Цена"), + callback_data=f"admin_server_edit_price_{server_id}" + ) ], [ InlineKeyboardButton( - text="❌ Отключить" if is_available else "✅ Включить", + text=_t(texts, "ADMIN_SERVER_EDIT_COUNTRY", "🌍 Страна"), + callback_data=f"admin_server_edit_country_{server_id}" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVER_EDIT_LIMIT", "👥 Лимит"), + callback_data=f"admin_server_edit_limit_{server_id}" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVER_EDIT_DESCRIPTION", "📝 Описание"), + callback_data=f"admin_server_edit_desc_{server_id}" + ) + ], + [ + InlineKeyboardButton( + text=toggle_text, callback_data=f"admin_server_toggle_{server_id}" ) ], [ - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"), - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list") + InlineKeyboardButton( + text=_t(texts, "ADMIN_SERVER_DELETE", "🗑️ Удалить"), + callback_data=f"admin_server_delete_{server_id}" + ), + InlineKeyboardButton(text=texts.BACK, callback_data="admin_servers_list") ] ]) @@ -1038,6 +1584,7 @@ def get_admin_pagination_keyboard( back_callback: str = "admin_panel", language: str = "ru" ) -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] if total_pages > 1: @@ -1063,74 +1610,75 @@ def get_admin_pagination_keyboard( keyboard.append(row) keyboard.append([ - InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback) + InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_maintenance_keyboard( - language: str, - is_maintenance_active: bool, + language: str, + is_maintenance_active: bool, is_monitoring_active: bool, panel_has_issues: bool = False ) -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [] - + if is_maintenance_active: keyboard.append([ InlineKeyboardButton( - text="🟢 Выключить техработы", + text=_t(texts, "ADMIN_MAINTENANCE_DISABLE", "🟢 Выключить техработы"), callback_data="maintenance_toggle" ) ]) else: keyboard.append([ InlineKeyboardButton( - text="🔧 Включить техработы", + text=_t(texts, "ADMIN_MAINTENANCE_ENABLE", "🔧 Включить техработы"), callback_data="maintenance_toggle" ) ]) - + if is_monitoring_active: keyboard.append([ InlineKeyboardButton( - text="⏹️ Остановить мониторинг", + text=_t(texts, "ADMIN_MAINTENANCE_STOP_MONITORING", "⏹️ Остановить мониторинг"), callback_data="maintenance_monitoring" ) ]) else: keyboard.append([ InlineKeyboardButton( - text="▶️ Запустить мониторинг", + text=_t(texts, "ADMIN_MAINTENANCE_START_MONITORING", "▶️ Запустить мониторинг"), callback_data="maintenance_monitoring" ) ]) - + keyboard.append([ InlineKeyboardButton( - text="🔍 Проверить API", + text=_t(texts, "ADMIN_MAINTENANCE_CHECK_API", "🔍 Проверить API"), callback_data="maintenance_check_api" ), InlineKeyboardButton( - text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""), + text=_t(texts, "ADMIN_MAINTENANCE_PANEL_STATUS", "🌐 Статус панели") + ("⚠️" if panel_has_issues else ""), callback_data="maintenance_check_panel" ) ]) - + keyboard.append([ InlineKeyboardButton( - text="📢 Отправить уведомление", + text=_t(texts, "ADMIN_MAINTENANCE_SEND_NOTIFICATION", "📢 Отправить уведомление"), callback_data="maintenance_manual_notify" ) ]) - + keyboard.append([ InlineKeyboardButton( - text="🔄 Обновить", + text=_t(texts, "ADMIN_REFRESH", "🔄 Обновить"), callback_data="maintenance_panel" ), InlineKeyboardButton( - text="⬅️ Назад", + text=texts.BACK, callback_data="admin_submenu_settings" ) ]) @@ -1138,37 +1686,62 @@ def get_maintenance_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) keyboard = [ - [InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), + callback_data="sync_all_users" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")] ] - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup: - - toggle_text = "🔴 Отключить" if is_enabled else "🟢 Включить" + + texts = get_texts(language) + toggle_text = _t(texts, "ADMIN_WELCOME_DISABLE", "🔴 Отключить") if is_enabled else _t(texts, "ADMIN_WELCOME_ENABLE", "🟢 Включить") toggle_callback = "toggle_welcome_text" - + keyboard = [ [ InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback) ], [ - InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"), - InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text") + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_EDIT", "📝 Изменить текст"), + callback_data="edit_welcome_text" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_SHOW", "👁️ Показать текущий"), + callback_data="show_welcome_text" + ) ], [ - InlineKeyboardButton(text="👁️ Предпросмотр", callback_data="preview_welcome_text"), - InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text") + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_PREVIEW", "👁️ Предпросмотр"), + callback_data="preview_welcome_text" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_RESET", "🔄 Сбросить"), + callback_data="reset_welcome_text" + ) ], [ - InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"), - InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help") + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_HTML", "🏷️ HTML форматирование"), + callback_data="show_formatting_help" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_WELCOME_PLACEHOLDERS", "💡 Плейсхолдеры"), + callback_data="show_placeholders_help" + ) ], [ - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications") + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") ] ] @@ -1177,13 +1750,41 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> DEFAULT_BROADCAST_BUTTONS = ("home",) BROADCAST_BUTTONS = { - "balance": {"text": "💰 Пополнить баланс", "callback": "balance_topup"}, - "referrals": {"text": "🤝 Партнерка", "callback": "menu_referrals"}, - "promocode": {"text": "🎫 Промокод", "callback": "menu_promocode"}, - "connect": {"text": "🔗 Подключиться", "callback": "subscription_connect"}, - "subscription": {"text": "📱 Подписка", "callback": "menu_subscription"}, - "support": {"text": "🛠️ Техподдержка", "callback": "menu_support"}, - "home": {"text": "🏠 На главную", "callback": "back_to_menu"}, + "balance": { + "default_text": "💰 Пополнить баланс", + "text_key": "ADMIN_BROADCAST_BUTTON_BALANCE", + "callback": "balance_topup", + }, + "referrals": { + "default_text": "🤝 Партнерка", + "text_key": "ADMIN_BROADCAST_BUTTON_REFERRALS", + "callback": "menu_referrals", + }, + "promocode": { + "default_text": "🎫 Промокод", + "text_key": "ADMIN_BROADCAST_BUTTON_PROMOCODE", + "callback": "menu_promocode", + }, + "connect": { + "default_text": "🔗 Подключиться", + "text_key": "ADMIN_BROADCAST_BUTTON_CONNECT", + "callback": "subscription_connect", + }, + "subscription": { + "default_text": "📱 Подписка", + "text_key": "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION", + "callback": "menu_subscription", + }, + "support": { + "default_text": "🛠️ Техподдержка", + "text_key": "ADMIN_BROADCAST_BUTTON_SUPPORT", + "callback": "menu_support", + }, + "home": { + "default_text": "🏠 На главную", + "text_key": "ADMIN_BROADCAST_BUTTON_HOME", + "callback": "back_to_menu", + }, } BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = ( @@ -1193,48 +1794,84 @@ BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = ( ("home",), ) -BROADCAST_BUTTON_LABELS = {key: value["text"] for key, value in BROADCAST_BUTTONS.items()} + +def get_broadcast_button_config(language: str) -> dict[str, dict[str, str]]: + texts = get_texts(language) + return { + key: { + "text": texts.t(config["text_key"], config["default_text"]), + "callback": config["callback"], + } + for key, config in BROADCAST_BUTTONS.items() + } + + +def get_broadcast_button_labels(language: str) -> dict[str, str]: + return {key: value["text"] for key, value in get_broadcast_button_config(language).items()} def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language) def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"), - InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_ADD_PHOTO", "📷 Добавить фото"), + callback_data="add_media_photo" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_ADD_VIDEO", "🎥 Добавить видео"), + callback_data="add_media_video" + ) ], [ - InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"), - InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_ADD_DOCUMENT", "📄 Добавить документ"), + callback_data="add_media_document" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_SKIP_MEDIA", "⏭️ Пропустить медиа"), + callback_data="skip_media" + ) ], - [ - InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") - ] + [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages")] ]) def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ - InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"), - InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_USE_MEDIA", "✅ Использовать это медиа"), + callback_data="confirm_media" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_REPLACE_MEDIA", "🔄 Заменить медиа"), + callback_data="replace_media" + ) ], [ - InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"), - InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_NO_MEDIA", "⏭️ Без медиа"), + callback_data="skip_media" + ), + InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages") ] ]) def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup: selected_buttons = selected_buttons or [] + texts = get_texts(language) + button_config_map = get_broadcast_button_config(language) keyboard: list[list[InlineKeyboardButton]] = [] for row in BROADCAST_BUTTON_ROWS: row_buttons: list[InlineKeyboardButton] = [] for button_key in row: - button_config = BROADCAST_BUTTONS[button_key] + button_config = button_config_map[button_key] base_text = button_config["text"] if button_key in selected_buttons: if " " in base_text: @@ -1251,15 +1888,21 @@ def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: l if has_media: keyboard.append([ - InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media") + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_CHANGE_MEDIA", "🖼️ Изменить медиа"), + callback_data="change_media" + ) ]) keyboard.extend([ [ - InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm") + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTINUE", "✅ Продолжить"), + callback_data="buttons_confirm" + ) ], [ - InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") + InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages") ] ]) diff --git a/locales/en.json b/locales/en.json index 8d289061..5bb6d5dc 100644 --- a/locales/en.json +++ b/locales/en.json @@ -208,7 +208,6 @@ "NO_TICKETS_ADMIN": "No tickets to display.", "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", @@ -536,5 +535,205 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):", + "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", + "ADMIN_MAIN_PROMO_STATS": "💰 Promo codes / Stats", + "ADMIN_MAIN_SUPPORT": "🛟 Support", + "ADMIN_MAIN_MESSAGES": "📨 Messages", + "ADMIN_MAIN_SETTINGS": "⚙️ Settings", + "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message", + "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages", + "ADMIN_SUPPORT_TICKETS": "🎫 Support tickets", + "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit", + "ADMIN_SUPPORT_SETTINGS": "🛟 Support settings", + "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration", + "ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance", + "ADMIN_SYSTEM_UPDATES": "📄 Updates", + "ADMIN_SYSTEM_BACKUPS": "🗄️ Backups", + "ADMIN_SYSTEM_LOGS": "🧾 Logs", + "ADMIN_REPORTS_PREVIOUS_DAY": "📆 Previous day", + "ADMIN_REPORTS_LAST_WEEK": "🗓️ Last week", + "ADMIN_REPORTS_LAST_MONTH": "📅 Last month", + "ADMIN_USERS_ALL": "👥 All users", + "ADMIN_USERS_SEARCH": "🔍 Search", + "ADMIN_USERS_INACTIVE": "🗑️ Inactive", + "ADMIN_USERS_FILTERS": "⚙️ Filters", + "ADMIN_USERS_FILTER_BALANCE": "💰 By balance", + "ADMIN_USERS_FILTER_TRAFFIC": "📶 By traffic", + "ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity", + "ADMIN_USERS_FILTER_SPENDING": "💳 By spending", + "ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases", + "ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign", + "ADMIN_SUBSCRIPTIONS_ALL": "📱 All subscriptions", + "ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Expiring soon", + "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Pricing settings", + "ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Manage countries", + "ADMIN_PROMOCODES_ALL": "🎫 All promo codes", + "ADMIN_PROMOCODES_CREATE": "➕ Create", + "ADMIN_PROMOCODES_GENERAL_STATS": "📊 Overall statistics", + "ADMIN_CAMPAIGNS_LIST": "📋 Campaign list", + "ADMIN_CAMPAIGNS_CREATE": "➕ Create", + "ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Overall statistics", + "ADMIN_CAMPAIGN_DISABLE": "🔴 Disable", + "ADMIN_CAMPAIGN_ENABLE": "🟢 Enable", + "ADMIN_CAMPAIGN_STATS": "📊 Statistics", + "ADMIN_CAMPAIGN_EDIT": "✏️ Edit", + "ADMIN_CAMPAIGN_DELETE": "🗑️ Delete", + "ADMIN_BACK_TO_LIST": "⬅️ Back to list", + "ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Name", + "ADMIN_CAMPAIGN_EDIT_START": "🔗 Parameter", + "ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Balance bonus", + "ADMIN_CAMPAIGN_DURATION": "📅 Duration", + "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic", + "ADMIN_CAMPAIGN_DEVICES": "📱 Devices", + "ADMIN_CAMPAIGN_SERVERS": "🌍 Servers", + "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Subscription bonus", + "ADMIN_PROMOCODE_EDIT": "✏️ Edit", + "ADMIN_PROMOCODE_TOGGLE": "🔄 Status", + "ADMIN_PROMOCODE_STATS": "📊 Statistics", + "ADMIN_PROMOCODE_DELETE": "🗑️ Delete", + "ADMIN_MESSAGES_ALL_USERS": "📨 All users", + "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions", + "ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria", + "ADMIN_MESSAGES_HISTORY": "📋 History", + "ADMIN_MONITORING_START": "▶️ Start", + "ADMIN_MONITORING_STOP": "⏸️ Stop", + "ADMIN_MONITORING_STATUS": "📊 Status", + "ADMIN_MONITORING_LOGS": "📋 Logs", + "ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Settings", + "ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 System statistics", + "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes", + "ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization", + "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads", + "ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic", + "ADMIN_STATS_USERS": "👥 Users", + "ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions", + "ADMIN_STATS_REVENUE": "💰 Revenue", + "ADMIN_STATS_REFERRALS": "🤝 Referrals", + "ADMIN_STATS_SUMMARY": "📊 Summary", + "ADMIN_STATS_BUTTON": "📊 Statistics", + "ADMIN_USER_BALANCE": "💰 Balance", + "ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Subscription & settings", + "ADMIN_USER_STATISTICS": "📊 Statistics", + "ADMIN_USER_TRANSACTIONS": "📋 Transactions", + "ADMIN_USER_BLOCK": "🚫 Block", + "ADMIN_USER_DELETE": "🗑️ Delete", + "ADMIN_USER_UNBLOCK": "✅ Unblock", + "ADMIN_USER_ALREADY_DELETED": "❌ User deleted", + "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance", + "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days", + "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial", + "ADMIN_BROADCAST_TARGET_ALL": "👥 Everyone", + "ADMIN_BROADCAST_TARGET_ACTIVE": "📱 With subscription", + "ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Trial", + "ADMIN_BROADCAST_TARGET_NO_SUB": "❌ No subscription", + "ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Expiring", + "ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Expired", + "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Active 0 GB", + "ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Trial 0 GB", + "ADMIN_CRITERIA_TODAY": "📅 Today", + "ADMIN_CRITERIA_WEEK": "📅 Last 7 days", + "ADMIN_CRITERIA_MONTH": "📅 Last month", + "ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Active today", + "ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Inactive 7+ days", + "ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Inactive 30+ days", + "ADMIN_CRITERIA_REFERRALS": "🤝 Via referrals", + "ADMIN_CRITERIA_PROMOCODES": "🎫 Used promo codes", + "ADMIN_CRITERIA_DIRECT": "🎯 Direct registration", + "ADMIN_HISTORY_REFRESH": "🔄 Refresh", + "ADMIN_SYNC_FULL": "🔄 Full sync", + "ADMIN_SYNC_ONLY_NEW": "🆕 Only new", + "ADMIN_SYNC_UPDATE": "📈 Update data", + "ADMIN_SYNC_VALIDATE": "🔍 Validate", + "ADMIN_SYNC_CLEANUP": "🧹 Cleanup", + "ADMIN_SYNC_RECOMMENDATIONS": "💡 Recommendations", + "ADMIN_SYNC_CONFIRM": "✅ Confirm", + "ADMIN_SYNC_RETRY": "🔄 Retry", + "ADMIN_SYNC_BACK": "⬅️ Back to sync", + "ADMIN_BACK_TO_MAIN": "🏠 Back to main menu", + "ADMIN_CANCEL": "❌ Cancel", + "ADMIN_CONTINUE": "✅ Continue", + "ADMIN_PERIOD_TODAY": "📅 Today", + "ADMIN_PERIOD_YESTERDAY": "📅 Yesterday", + "ADMIN_PERIOD_WEEK": "📅 Week", + "ADMIN_PERIOD_MONTH": "📅 Month", + "ADMIN_PERIOD_ALL": "📅 All time", + "ADMIN_NODE_ENABLE": "▶️ Enable", + "ADMIN_NODE_DISABLE": "⏸️ Disable", + "ADMIN_NODE_RESTART": "🔄 Restart", + "ADMIN_NODE_STATS": "📊 Statistics", + "ADMIN_SQUAD_ADD_ALL": "👥 Add all users", + "ADMIN_SQUAD_REMOVE_ALL": "❌ Remove all users", + "ADMIN_SQUAD_EDIT": "✏️ Edit", + "ADMIN_SQUAD_DELETE": "🗑️ Delete squad", + "ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Edit inbounds", + "ADMIN_SQUAD_RENAME": "✏️ Rename", + "ADMIN_BACK_TO_SQUADS": "⬅️ Back to squads", + "ADMIN_MONITORING_STOP_HARD": "⏹️ Stop", + "ADMIN_MONITORING_FORCE_CHECK": "🔄 Force check", + "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications", + "ADMIN_MONITORING_STATISTICS": "📊 Statistics", + "ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin", + "ADMIN_MONITORING_CLEAR_OLD": "🗑️ Clear old", + "ADMIN_MONITORING_CLEAR": "🗑️ Clear", + "ADMIN_BACK_TO_MONITORING": "⬅️ Back to monitoring", + "ADMIN_MONITORING_DELETE_LOG": "🗑️ Delete this log", + "ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ Back to log list", + "ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Yes, clear", + "ADMIN_MONITORING_CLEAR_ALL": "🗑️ Clear ALL logs", + "ADMIN_MONITORING_RESTART": "🔄 Restart", + "ADMIN_MONITORING_CHECK_NOW": "🔄 Check now", + "ADMIN_MONITORING_SET_INTERVAL": "⏱️ Check interval", + "ADMIN_MONITORING_NOTIFICATIONS": "🔔 Notifications", + "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings", + "ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Auto-clean logs", + "ADMIN_MONITORING_FILTER_SUCCESS": "✅ Success", + "ADMIN_MONITORING_FILTER_ERRORS": "❌ Errors", + "ADMIN_MONITORING_FILTER_CYCLES": "🔄 Monitoring cycles", + "ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Auto-payments", + "ADMIN_MONITORING_ALL_LOGS": "📋 All logs", + "ADMIN_SERVERS_LIST": "📋 Server list", + "ADMIN_SERVERS_SYNC": "🔄 Synchronization", + "ADMIN_SERVERS_ADD": "➕ Add server", + "ADMIN_SERVERS_STATS": "📊 Statistics", + "ADMIN_SERVER_DISABLE": "❌ Disable", + "ADMIN_SERVER_ENABLE": "✅ Enable", + "ADMIN_SERVER_EDIT_NAME": "✏️ Name", + "ADMIN_SERVER_EDIT_PRICE": "💰 Price", + "ADMIN_SERVER_EDIT_COUNTRY": "🌍 Country", + "ADMIN_SERVER_EDIT_LIMIT": "👥 Limit", + "ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Description", + "ADMIN_SERVER_DELETE": "🗑️ Delete", + "ADMIN_MAINTENANCE_DISABLE": "🟢 Disable maintenance", + "ADMIN_MAINTENANCE_ENABLE": "🔧 Enable maintenance", + "ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Stop monitoring", + "ADMIN_MAINTENANCE_START_MONITORING": "▶️ Start monitoring", + "ADMIN_MAINTENANCE_CHECK_API": "🔍 Check API", + "ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Panel status", + "ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Send notification", + "ADMIN_REFRESH": "🔄 Refresh", + "ADMIN_WELCOME_DISABLE": "🔴 Disable", + "ADMIN_WELCOME_ENABLE": "🟢 Enable", + "ADMIN_WELCOME_EDIT": "📝 Edit text", + "ADMIN_WELCOME_SHOW": "👁️ Show current", + "ADMIN_WELCOME_PREVIEW": "👁️ Preview", + "ADMIN_WELCOME_RESET": "🔄 Reset", + "ADMIN_WELCOME_HTML": "🏷️ HTML formatting", + "ADMIN_WELCOME_PLACEHOLDERS": "💡 Placeholders", + "ADMIN_BROADCAST_ADD_PHOTO": "📷 Add photo", + "ADMIN_BROADCAST_ADD_VIDEO": "🎥 Add video", + "ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Add document", + "ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Skip media", + "ADMIN_BROADCAST_USE_MEDIA": "✅ Use this media", + "ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Replace media", + "ADMIN_BROADCAST_NO_MEDIA": "⏭️ No media", + "ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Change media", + "ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Top up balance", + "ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Referrals", + "ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Promo code", + "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect", + "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription", + "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support", + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu" } diff --git a/locales/ru.json b/locales/ru.json index 5f0bdc0b..fe1afab8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -72,7 +72,6 @@ "NO_TICKETS_ADMIN": "Нет тикетов для отображения.", "ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:", "ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:", - "ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!", "TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.", "TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.", @@ -536,5 +535,205 @@ "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", - "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):", + "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", + "ADMIN_MAIN_PROMO_STATS": "💰 Промокоды/Статистика", + "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", + "ADMIN_MAIN_MESSAGES": "📨 Сообщения", + "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", + "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст", + "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню", + "ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки", + "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов", + "ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки", + "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота", + "ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы", + "ADMIN_SYSTEM_UPDATES": "📄 Обновления", + "ADMIN_SYSTEM_BACKUPS": "🗄️ Резервные копии", + "ADMIN_SYSTEM_LOGS": "🧾 Логи", + "ADMIN_REPORTS_PREVIOUS_DAY": "📆 За вчера", + "ADMIN_REPORTS_LAST_WEEK": "🗓️ За неделю", + "ADMIN_REPORTS_LAST_MONTH": "📅 За месяц", + "ADMIN_USERS_ALL": "👥 Все пользователи", + "ADMIN_USERS_SEARCH": "🔍 Поиск", + "ADMIN_USERS_INACTIVE": "🗑️ Неактивные", + "ADMIN_USERS_FILTERS": "⚙️ Фильтры", + "ADMIN_USERS_FILTER_BALANCE": "💰 По балансу", + "ADMIN_USERS_FILTER_TRAFFIC": "📶 По трафику", + "ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности", + "ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат", + "ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок", + "ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании", + "ADMIN_SUBSCRIPTIONS_ALL": "📱 Все подписки", + "ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Истекающие", + "ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Настройки цен", + "ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Управление странами", + "ADMIN_PROMOCODES_ALL": "🎫 Все промокоды", + "ADMIN_PROMOCODES_CREATE": "➕ Создать", + "ADMIN_PROMOCODES_GENERAL_STATS": "📊 Общая статистика", + "ADMIN_CAMPAIGNS_LIST": "📋 Список кампаний", + "ADMIN_CAMPAIGNS_CREATE": "➕ Создать", + "ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Общая статистика", + "ADMIN_CAMPAIGN_DISABLE": "🔴 Выключить", + "ADMIN_CAMPAIGN_ENABLE": "🟢 Включить", + "ADMIN_CAMPAIGN_STATS": "📊 Статистика", + "ADMIN_CAMPAIGN_EDIT": "✏️ Редактировать", + "ADMIN_CAMPAIGN_DELETE": "🗑️ Удалить", + "ADMIN_BACK_TO_LIST": "⬅️ К списку", + "ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Название", + "ADMIN_CAMPAIGN_EDIT_START": "🔗 Параметр", + "ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Бонус на баланс", + "ADMIN_CAMPAIGN_DURATION": "📅 Длительность", + "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик", + "ADMIN_CAMPAIGN_DEVICES": "📱 Устройства", + "ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы", + "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Бонус на подписку", + "ADMIN_PROMOCODE_EDIT": "✏️ Редактировать", + "ADMIN_PROMOCODE_TOGGLE": "🔄 Статус", + "ADMIN_PROMOCODE_STATS": "📊 Статистика", + "ADMIN_PROMOCODE_DELETE": "🗑️ Удалить", + "ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям", + "ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам", + "ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям", + "ADMIN_MESSAGES_HISTORY": "📋 История", + "ADMIN_MONITORING_START": "▶️ Запустить", + "ADMIN_MONITORING_STOP": "⏸️ Остановить", + "ADMIN_MONITORING_STATUS": "📊 Статус", + "ADMIN_MONITORING_LOGS": "📋 Логи", + "ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Настройки", + "ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 Системная статистика", + "ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами", + "ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация", + "ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами", + "ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик", + "ADMIN_STATS_USERS": "👥 Пользователи", + "ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки", + "ADMIN_STATS_REVENUE": "💰 Доходы", + "ADMIN_STATS_REFERRALS": "🤝 Партнерка", + "ADMIN_STATS_SUMMARY": "📊 Общая сводка", + "ADMIN_STATS_BUTTON": "📊 Статистика", + "ADMIN_USER_BALANCE": "💰 Баланс", + "ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Подписка и настройки", + "ADMIN_USER_STATISTICS": "📊 Статистика", + "ADMIN_USER_TRANSACTIONS": "📋 Транзакции", + "ADMIN_USER_BLOCK": "🚫 Заблокировать", + "ADMIN_USER_DELETE": "🗑️ Удалить", + "ADMIN_USER_UNBLOCK": "✅ Разблокировать", + "ADMIN_USER_ALREADY_DELETED": "❌ Пользователь удален", + "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс", + "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки", + "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал", + "ADMIN_BROADCAST_TARGET_ALL": "👥 Всем", + "ADMIN_BROADCAST_TARGET_ACTIVE": "📱 С подпиской", + "ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Триал", + "ADMIN_BROADCAST_TARGET_NO_SUB": "❌ Без подписки", + "ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Истекающие", + "ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Истекшие", + "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Активна 0 ГБ", + "ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Триал 0 ГБ", + "ADMIN_CRITERIA_TODAY": "📅 Сегодня", + "ADMIN_CRITERIA_WEEK": "📅 За неделю", + "ADMIN_CRITERIA_MONTH": "📅 За месяц", + "ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Активные сегодня", + "ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Неактивные 7+ дней", + "ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Неактивные 30+ дней", + "ADMIN_CRITERIA_REFERRALS": "🤝 Через рефералов", + "ADMIN_CRITERIA_PROMOCODES": "🎫 Использовали промокоды", + "ADMIN_CRITERIA_DIRECT": "🎯 Прямая регистрация", + "ADMIN_HISTORY_REFRESH": "🔄 Обновить", + "ADMIN_SYNC_FULL": "🔄 Полная синхронизация", + "ADMIN_SYNC_ONLY_NEW": "🆕 Только новые", + "ADMIN_SYNC_UPDATE": "📈 Обновить данные", + "ADMIN_SYNC_VALIDATE": "🔍 Валидация", + "ADMIN_SYNC_CLEANUP": "🧹 Очистка", + "ADMIN_SYNC_RECOMMENDATIONS": "💡 Рекомендации", + "ADMIN_SYNC_CONFIRM": "✅ Подтвердить", + "ADMIN_SYNC_RETRY": "🔄 Повторить", + "ADMIN_SYNC_BACK": "⬅️ К синхронизации", + "ADMIN_BACK_TO_MAIN": "🏠 В главное меню", + "ADMIN_CANCEL": "❌ Отмена", + "ADMIN_CONTINUE": "✅ Продолжить", + "ADMIN_PERIOD_TODAY": "📅 Сегодня", + "ADMIN_PERIOD_YESTERDAY": "📅 Вчера", + "ADMIN_PERIOD_WEEK": "📅 Неделя", + "ADMIN_PERIOD_MONTH": "📅 Месяц", + "ADMIN_PERIOD_ALL": "📅 Все время", + "ADMIN_NODE_ENABLE": "▶️ Включить", + "ADMIN_NODE_DISABLE": "⏸️ Отключить", + "ADMIN_NODE_RESTART": "🔄 Перезагрузить", + "ADMIN_NODE_STATS": "📊 Статистика", + "ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей", + "ADMIN_SQUAD_REMOVE_ALL": "❌ Удалить всех пользователей", + "ADMIN_SQUAD_EDIT": "✏️ Редактировать", + "ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад", + "ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Изменить инбаунды", + "ADMIN_SQUAD_RENAME": "✏️ Переименовать", + "ADMIN_BACK_TO_SQUADS": "⬅️ Назад к сквадам", + "ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить", + "ADMIN_MONITORING_FORCE_CHECK": "🔄 Принудительная проверка", + "ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений", + "ADMIN_MONITORING_STATISTICS": "📊 Статистика", + "ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку", + "ADMIN_MONITORING_CLEAR_OLD": "🗑️ Очистить старые", + "ADMIN_MONITORING_CLEAR": "🗑️ Очистить", + "ADMIN_BACK_TO_MONITORING": "⬅️ Назад к мониторингу", + "ADMIN_MONITORING_DELETE_LOG": "🗑️ Удалить этот лог", + "ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ К списку логов", + "ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Да, очистить", + "ADMIN_MONITORING_CLEAR_ALL": "🗑️ Очистить ВСЕ логи", + "ADMIN_MONITORING_RESTART": "🔄 Перезапустить", + "ADMIN_MONITORING_CHECK_NOW": "🔄 Проверить сейчас", + "ADMIN_MONITORING_SET_INTERVAL": "⏱️ Интервал проверки", + "ADMIN_MONITORING_NOTIFICATIONS": "🔔 Уведомления", + "ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты", + "ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочистка логов", + "ADMIN_MONITORING_FILTER_SUCCESS": "✅ Успешные", + "ADMIN_MONITORING_FILTER_ERRORS": "❌ Ошибки", + "ADMIN_MONITORING_FILTER_CYCLES": "🔄 Циклы мониторинга", + "ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Автооплаты", + "ADMIN_MONITORING_ALL_LOGS": "📋 Все логи", + "ADMIN_SERVERS_LIST": "📋 Список серверов", + "ADMIN_SERVERS_SYNC": "🔄 Синхронизация", + "ADMIN_SERVERS_ADD": "➕ Добавить сервер", + "ADMIN_SERVERS_STATS": "📊 Статистика", + "ADMIN_SERVER_DISABLE": "❌ Отключить", + "ADMIN_SERVER_ENABLE": "✅ Включить", + "ADMIN_SERVER_EDIT_NAME": "✏️ Название", + "ADMIN_SERVER_EDIT_PRICE": "💰 Цена", + "ADMIN_SERVER_EDIT_COUNTRY": "🌍 Страна", + "ADMIN_SERVER_EDIT_LIMIT": "👥 Лимит", + "ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Описание", + "ADMIN_SERVER_DELETE": "🗑️ Удалить", + "ADMIN_MAINTENANCE_DISABLE": "🟢 Выключить техработы", + "ADMIN_MAINTENANCE_ENABLE": "🔧 Включить техработы", + "ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Остановить мониторинг", + "ADMIN_MAINTENANCE_START_MONITORING": "▶️ Запустить мониторинг", + "ADMIN_MAINTENANCE_CHECK_API": "🔍 Проверить API", + "ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Статус панели", + "ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Отправить уведомление", + "ADMIN_REFRESH": "🔄 Обновить", + "ADMIN_WELCOME_DISABLE": "🔴 Отключить", + "ADMIN_WELCOME_ENABLE": "🟢 Включить", + "ADMIN_WELCOME_EDIT": "📝 Изменить текст", + "ADMIN_WELCOME_SHOW": "👁️ Показать текущий", + "ADMIN_WELCOME_PREVIEW": "👁️ Предпросмотр", + "ADMIN_WELCOME_RESET": "🔄 Сбросить", + "ADMIN_WELCOME_HTML": "🏷️ HTML форматирование", + "ADMIN_WELCOME_PLACEHOLDERS": "💡 Плейсхолдеры", + "ADMIN_BROADCAST_ADD_PHOTO": "📷 Добавить фото", + "ADMIN_BROADCAST_ADD_VIDEO": "🎥 Добавить видео", + "ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Добавить документ", + "ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Пропустить медиа", + "ADMIN_BROADCAST_USE_MEDIA": "✅ Использовать это медиа", + "ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Заменить медиа", + "ADMIN_BROADCAST_NO_MEDIA": "⏭️ Без медиа", + "ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Изменить медиа", + "ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Пополнить баланс", + "ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Партнерка", + "ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Промокод", + "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться", + "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка", + "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка", + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную" } From 845faf037f76fe3ef6d51a63edadb98e9a61194b Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 23:51:29 +0300 Subject: [PATCH 13/24] Improve MulenPay webhook signature handling --- app/external/webhook_server.py | 65 +++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 9ea90945..f90bdb38 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -151,30 +151,79 @@ class WebhookServer: logger.error("Mulen Pay secret key is not configured") return False - signature = request.headers.get('X-MulenPay-Signature') + signature = None + signature_header_candidates = { + 'x-mulenpay-signature', + 'x-mulenpay-signature-256', + 'x-mulenpay-webhook-signature', + 'mulenpay-signature', + 'x-signature', + 'signature', + } + + for header_name, header_value in request.headers.items(): + header_name_lower = header_name.lower() + if ( + header_name_lower in signature_header_candidates + or ('mulen' in header_name_lower and 'signature' in header_name_lower) + ) and header_value: + signature = header_value + break + if signature: + signature = signature.strip() + + if '=' in signature: + prefix, _, suffix = signature.partition('=') + if prefix.lower() in {'sha256', 'sha1'} and suffix: + signature = suffix.strip() + expected_signature = hmac.new( secret_key.encode('utf-8'), raw_body, hashlib.sha256, ).hexdigest() - if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()): + if hmac.compare_digest(signature, expected_signature): + return True + + if hmac.compare_digest(signature.lower(), expected_signature.lower()): return True logger.error("Неверная подпись Mulen Pay webhook") return False authorization_header = request.headers.get('Authorization') - if authorization_header and authorization_header.startswith('Bearer '): - token = authorization_header.split(' ', 1)[1].strip() - if hmac.compare_digest(token, secret_key): - return True + if authorization_header: + scheme, _, token = authorization_header.partition(' ') + if not token: + token = scheme + scheme = '' - logger.error("Неверный Bearer токен Mulen Pay webhook") + token = token.strip() + scheme = scheme.lower() + + valid_tokens = [secret_key] + if settings.MULENPAY_API_KEY: + valid_tokens.append(settings.MULENPAY_API_KEY) + + for valid_token in valid_tokens: + if valid_token and hmac.compare_digest(token, valid_token): + return True + + if scheme == 'bearer': + logger.error("Неверный Bearer токен Mulen Pay webhook") + else: + logger.error( + "Неверный токен авторизации Mulen Pay webhook (scheme=%s)", + scheme or 'unknown', + ) return False - logger.error("Отсутствует подпись Mulen Pay webhook") + logger.error( + "Отсутствует подпись Mulen Pay webhook. Headers: %s", + {k: v for k, v in request.headers.items()}, + ) return False async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: From 533087b3330d458546491b316b250a0b9b97f6fe Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 23:56:54 +0300 Subject: [PATCH 14/24] Revert "Improve MulenPay webhook signature handling" --- app/external/webhook_server.py | 65 +++++----------------------------- 1 file changed, 8 insertions(+), 57 deletions(-) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index f90bdb38..9ea90945 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -151,79 +151,30 @@ class WebhookServer: logger.error("Mulen Pay secret key is not configured") return False - signature = None - signature_header_candidates = { - 'x-mulenpay-signature', - 'x-mulenpay-signature-256', - 'x-mulenpay-webhook-signature', - 'mulenpay-signature', - 'x-signature', - 'signature', - } - - for header_name, header_value in request.headers.items(): - header_name_lower = header_name.lower() - if ( - header_name_lower in signature_header_candidates - or ('mulen' in header_name_lower and 'signature' in header_name_lower) - ) and header_value: - signature = header_value - break - + signature = request.headers.get('X-MulenPay-Signature') if signature: - signature = signature.strip() - - if '=' in signature: - prefix, _, suffix = signature.partition('=') - if prefix.lower() in {'sha256', 'sha1'} and suffix: - signature = suffix.strip() - expected_signature = hmac.new( secret_key.encode('utf-8'), raw_body, hashlib.sha256, ).hexdigest() - if hmac.compare_digest(signature, expected_signature): - return True - - if hmac.compare_digest(signature.lower(), expected_signature.lower()): + if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()): return True logger.error("Неверная подпись Mulen Pay webhook") return False authorization_header = request.headers.get('Authorization') - if authorization_header: - scheme, _, token = authorization_header.partition(' ') - if not token: - token = scheme - scheme = '' + if authorization_header and authorization_header.startswith('Bearer '): + token = authorization_header.split(' ', 1)[1].strip() + if hmac.compare_digest(token, secret_key): + return True - token = token.strip() - scheme = scheme.lower() - - valid_tokens = [secret_key] - if settings.MULENPAY_API_KEY: - valid_tokens.append(settings.MULENPAY_API_KEY) - - for valid_token in valid_tokens: - if valid_token and hmac.compare_digest(token, valid_token): - return True - - if scheme == 'bearer': - logger.error("Неверный Bearer токен Mulen Pay webhook") - else: - logger.error( - "Неверный токен авторизации Mulen Pay webhook (scheme=%s)", - scheme or 'unknown', - ) + logger.error("Неверный Bearer токен Mulen Pay webhook") return False - logger.error( - "Отсутствует подпись Mulen Pay webhook. Headers: %s", - {k: v for k, v in request.headers.items()}, - ) + logger.error("Отсутствует подпись Mulen Pay webhook") return False async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: From 2b4136faa78b34e9b3d18fb13d739432250d28cd Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 30 Sep 2025 23:57:18 +0300 Subject: [PATCH 15/24] Handle additional MulenPay webhook signature formats --- app/external/webhook_server.py | 79 +++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 9ea90945..cb0d22de 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -1,8 +1,9 @@ +import base64 import hashlib import hmac import logging import json -from typing import Optional +from typing import Optional, Iterable from aiohttp import web from aiogram import Bot @@ -144,6 +145,14 @@ class WebhookServer: logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True) return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500) + @staticmethod + def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]: + for header_name in header_names: + value = request.headers.get(header_name) + if value: + return value.strip() + return None + @staticmethod def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool: secret_key = settings.MULENPAY_SECRET_KEY @@ -151,28 +160,78 @@ class WebhookServer: logger.error("Mulen Pay secret key is not configured") return False - signature = request.headers.get('X-MulenPay-Signature') + signature = WebhookServer._extract_mulenpay_header( + request, + ( + 'X-MulenPay-Signature', + 'X-Mulenpay-Signature', + 'X-MULENPAY-SIGNATURE', + 'X-MulenPay-Webhook-Signature', + 'X-Mulenpay-Webhook-Signature', + 'X-MULENPAY-WEBHOOK-SIGNATURE', + 'X-Signature', + 'Signature', + ) + ) if signature: - expected_signature = hmac.new( + normalized_signature = signature + if normalized_signature.lower().startswith('sha256='): + normalized_signature = normalized_signature.split('=', 1)[1].strip() + + hmac_digest = hmac.new( secret_key.encode('utf-8'), raw_body, hashlib.sha256, - ).hexdigest() + ).digest() + expected_hex_signature = hmac_digest.hex() + expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip() + expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip() - if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()): + normalized_signature_lower = normalized_signature.lower() + if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()): + return True + + normalized_signature_no_padding = normalized_signature.rstrip('=') + if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')): + return True + + if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')): return True logger.error("Неверная подпись Mulen Pay webhook") return False authorization_header = request.headers.get('Authorization') - if authorization_header and authorization_header.startswith('Bearer '): - token = authorization_header.split(' ', 1)[1].strip() - if hmac.compare_digest(token, secret_key): + if authorization_header: + scheme, _, value = authorization_header.partition(' ') + scheme_lower = scheme.lower() + token = value.strip() if value else scheme.strip() + + if scheme_lower in ('bearer', 'token'): + if hmac.compare_digest(token, secret_key): + return True + + logger.error("Неверный %s токен Mulen Pay webhook", scheme) + return False + + if not value and hmac.compare_digest(token, secret_key): return True - logger.error("Неверный Bearer токен Mulen Pay webhook") - return False + fallback_token = WebhookServer._extract_mulenpay_header( + request, + ( + 'X-MulenPay-Token', + 'X-Mulenpay-Token', + 'X-Webhook-Token', + ) + ) + if fallback_token and hmac.compare_digest(fallback_token, secret_key): + return True + + logger.debug( + "Mulen Pay webhook headers received: %s", + {key: value for key, value in request.headers.items() if 'authorization' not in key.lower()} + ) logger.error("Отсутствует подпись Mulen Pay webhook") return False From c12f3d443ce7070bbc9961c6b804df39141711a4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 00:08:03 +0300 Subject: [PATCH 16/24] Add English localization for admin support menus --- app/handlers/admin/main.py | 69 +++++----- app/handlers/admin/support_settings.py | 170 ++++++++++++++++--------- app/localization/locales/en.json | 60 ++++++++- app/localization/locales/ru.json | 60 ++++++++- locales/en.json | 58 +++++++++ locales/ru.json | 58 +++++++++ 6 files changed, 383 insertions(+), 92 deletions(-) diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 3be7d267..c6f2bbe1 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -71,10 +71,10 @@ async def show_users_submenu( db: AsyncSession ): texts = get_texts(db_user.language) - + await callback.message.edit_text( - "👥 **Управление пользователями и подписками**\n\n" - "Выберите нужный раздел:", + texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") + + texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"), reply_markup=get_admin_users_submenu_keyboard(db_user.language), parse_mode="Markdown" ) @@ -89,10 +89,10 @@ async def show_promo_submenu( db: AsyncSession ): texts = get_texts(db_user.language) - + await callback.message.edit_text( - "💰 **Промокоды и статистика**\n\n" - "Выберите нужный раздел:", + texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") + + texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"), reply_markup=get_admin_promo_submenu_keyboard(db_user.language), parse_mode="Markdown" ) @@ -107,10 +107,10 @@ async def show_communications_submenu( db: AsyncSession ): texts = get_texts(db_user.language) - + await callback.message.edit_text( - "📨 **Коммуникации**\n\n" - "Управление рассылками и текстами интерфейса:", + texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") + + texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), parse_mode="Markdown" ) @@ -132,11 +132,15 @@ async def show_support_submenu( if is_moderator_only: # Rebuild keyboard to include only tickets and back to main menu kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")] + [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] ]) await callback.message.edit_text( - "🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"), + texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + ( + texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.") + if is_moderator_only + else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:") + ), reply_markup=kb, parse_mode="Markdown" ) @@ -149,12 +153,14 @@ async def show_moderator_panel( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")], - [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")] + [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")], + [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")] ]) await callback.message.edit_text( - "🧑‍⚖️ Модерация поддержки\n\nДоступ к тикетам поддержки.", + texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ Модерация поддержки") + "\n\n" + + texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."), parse_mode="HTML", reply_markup=kb ) @@ -168,6 +174,7 @@ async def show_support_audit( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) # pagination page = 1 if callback.data.startswith("admin_support_audit_page_"): @@ -185,18 +192,22 @@ async def show_support_audit( offset = (page - 1) * per_page logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset) - lines = ["🧾 Аудит модераторов", ""] + lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 Аудит модераторов"), ""] if not logs: - lines.append("Пока пусто") + lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто")) else: for log in logs: - role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ" + role = ( + texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор") + if getattr(log, 'is_moderator', False) + else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ") + ) ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else '' action_map = { - 'close_ticket': 'Закрытие тикета', - 'block_user_timed': 'Блокировка (время)', - 'block_user_perm': 'Блокировка (навсегда)', - 'unblock_user': 'Снятие блока', + 'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"), + 'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"), + 'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"), + 'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"), } action_text = action_map.get(log.action, log.action) ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else "" @@ -218,7 +229,7 @@ async def show_support_audit( kb_rows = [] if nav_row: kb_rows.append(nav_row) - kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")]) + kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")]) kb = InlineKeyboardMarkup(inline_keyboard=kb_rows) await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb) @@ -233,10 +244,10 @@ async def show_settings_submenu( db: AsyncSession ): texts = get_texts(db_user.language) - + await callback.message.edit_text( - "⚙️ **Настройки системы**\n\n" - "Управление Remnawave, мониторингом и другими настройками:", + texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") + + texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"), reply_markup=get_admin_settings_submenu_keyboard(db_user.language), parse_mode="Markdown" ) @@ -251,10 +262,10 @@ async def show_system_submenu( db: AsyncSession ): texts = get_texts(db_user.language) - + await callback.message.edit_text( - "🛠️ **Системные функции**\n\n" - "Отчеты, обновления, логи, резервные копии и системные операции:", + texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") + + texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"), reply_markup=get_admin_system_submenu_keyboard(db_user.language), parse_mode="Markdown" ) diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index 5ab4d3af..ffd9c27f 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -29,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: rows: list[list[types.InlineKeyboardButton]] = [] + status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены") + status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены") + + def mode_button(label_key: str, default: str, active: bool) -> str: + prefix = "🔘" if active else "⚪" + return f"{prefix} {texts.t(label_key, default)}" + rows.append([ types.InlineKeyboardButton( - text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"), + text=( + f"{'✅' if menu_enabled else '🚫'} " + f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}" + ), callback_data="admin_support_toggle_menu" ) ]) rows.append([ - types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"), - types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"), - types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"), + types.InlineKeyboardButton( + text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"), + callback_data="admin_support_mode_tickets" + ), + types.InlineKeyboardButton( + text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"), + callback_data="admin_support_mode_contact" + ), + types.InlineKeyboardButton( + text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"), + callback_data="admin_support_mode_both" + ), ]) rows.append([ - types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc") + types.InlineKeyboardButton( + text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"), + callback_data="admin_support_edit_desc" + ) ]) # Notifications block rows.append([ types.InlineKeyboardButton( - text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"), + text=( + f"{'🔔' if admin_notif else '🔕'} " + f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: " + f"{status_enabled if admin_notif else status_disabled}" + ), callback_data="admin_support_toggle_admin_notifications" ) ]) rows.append([ types.InlineKeyboardButton( - text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"), + text=( + f"{'🔔' if user_notif else '🔕'} " + f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: " + f"{status_enabled if user_notif else status_disabled}" + ), callback_data="admin_support_toggle_user_notifications" ) ]) @@ -63,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: # SLA block rows.append([ types.InlineKeyboardButton( - text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"), + text=( + f"{'⏰' if sla_enabled else '⏹️'} " + f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: " + f"{status_enabled if sla_enabled else status_disabled}" + ), callback_data="admin_support_toggle_sla" ) ]) rows.append([ types.InlineKeyboardButton( - text=f"⏳ Время SLA: {sla_minutes} мин", + text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes), callback_data="admin_support_set_sla_minutes" ) ]) @@ -79,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: mod_count = len(moderators) rows.append([ types.InlineKeyboardButton( - text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators" + text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count), + callback_data="admin_support_list_moderators" ) ]) rows.append([ types.InlineKeyboardButton( - text="➕ Назначить модератора", callback_data="admin_support_add_moderator" + text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"), + callback_data="admin_support_add_moderator" ), types.InlineKeyboardButton( - text="➖ Удалить модератора", callback_data="admin_support_remove_moderator" + text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"), + callback_data="admin_support_remove_moderator" ) ]) @@ -108,8 +145,8 @@ async def show_support_settings( texts = get_texts(db_user.language) desc = SupportSettingsService.get_support_info_text(db_user.language) await callback.message.edit_text( - "🛟 Настройки поддержки\n\n" + - "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" + + texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 Настройки поддержки") + "\n\n" + + texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" + desc, reply_markup=_get_support_settings_keyboard(db_user.language), parse_mode="HTML" @@ -161,11 +198,15 @@ class SupportAdvancedStates(StatesGroup): @admin_required @error_handler async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) await callback.message.edit_text( - "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + texts.t( + "ADMIN_SUPPORT_SLA_SETUP_PROMPT", + "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):" + ), parse_mode="HTML", reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] ) ) await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes) @@ -175,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db @admin_required @error_handler async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) text = (message.text or "").strip() try: minutes = int(text) if minutes <= 0 or minutes > 1440: raise ValueError() except Exception: - await message.answer("❌ Введите корректное число минут (1-1440)") + await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)")) return SupportSettingsService.set_sla_minutes(minutes) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] ) - await message.answer("✅ Значение SLA сохранено", reply_markup=markup) + await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup) @admin_required @error_handler async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) await callback.message.edit_text( - "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + texts.t( + "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT", + "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)" + ), parse_mode="HTML", reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] ) ) await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) await callback.answer() -@admin_required -@error_handler -async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): - text = (message.text or "").strip() - try: - tid = int(text) - except Exception: - await message.answer("❌ Введите корректный Telegram ID (число)") - return - if SupportSettingsService.add_moderator(tid): - markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] - ) - await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup) - else: - await message.answer("❌ Не удалось сохранить") - await state.clear() - - @admin_required @error_handler async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) await callback.message.edit_text( - "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + texts.t( + "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT", + "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)" + ), parse_mode="HTML", reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] ) ) await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) @@ -243,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d @admin_required @error_handler async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) data = await state.get_data() action = data.get("action", "add") text = (message.text or "").strip() try: tid = int(text) except Exception: - await message.answer("❌ Введите корректный Telegram ID (число)") + await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)")) return - ok = False if action == "remove_moderator": ok = SupportSettingsService.remove_moderator(tid) - msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить" + msg = ( + texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid) + if ok + else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора") + ) else: ok = SupportSettingsService.add_moderator(tid) - msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить" + msg = ( + texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid) + if ok + else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора") + ) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] ) await message.answer(msg, reply_markup=markup) @@ -268,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe @admin_required @error_handler async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + texts = get_texts(db_user.language) moderators = SupportSettingsService.get_moderators() if not moderators: - await callback.answer("Список пуст", show_alert=True) + await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True) return - text = "🧑‍⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + text = ( + texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ Модераторы") + + "\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + ) markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] ) await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup) await callback.answer() @@ -311,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn kb_rows: list[list[types.InlineKeyboardButton]] = [] kb_rows.append([ - types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc") + types.InlineKeyboardButton( + text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"), + callback_data="admin_support_send_desc" + ) ]) # Подготовим блок контакта (отдельным инлайном) from app.config import settings @@ -321,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn ]) text_parts = [ - "📝 Редактирование описания поддержки", + texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 Редактирование описания поддержки"), "", - "Текущее описание:", + texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"), "", f"{html.escape(current_desc_plain)}", ] if support_contact_display: text_parts += [ "", - "Контакт для режима \u00abКонтакт\u00bb", + texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "Контакт для режима «Контакт»"), f"{html.escape(support_contact_display)}", "", - "Добавьте в описание при необходимости.", + texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."), ] await callback.message.edit_text( "\n".join(text_parts), @@ -347,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn @admin_required @error_handler async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): + texts = get_texts(db_user.language) new_text = message.html_text or message.text SupportSettingsService.set_support_info_text(db_user.language, new_text) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] ) - await message.answer("✅ Описание обновлено.", reply_markup=markup) + await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup) @admin_required @error_handler async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # send plain text for easy copying + texts = get_texts(db_user.language) current_desc_html = SupportSettingsService.get_support_info_text(db_user.language) current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html) # attach delete button to the sent message markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] ) if len(current_desc_plain) <= 4000: await callback.message.answer(current_desc_plain, reply_markup=markup) @@ -376,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async is_last = (chunk + 4000) >= len(current_desc_plain) await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None)) chunk += 4000 - await callback.answer("Текст отправлен ниже") + await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже")) @error_handler @@ -386,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)) except Exception: may_delete = False + texts = get_texts(db_user.language if db_user else 'ru') if not may_delete: - texts = get_texts(db_user.language if db_user else 'ru') await callback.answer(texts.ACCESS_DENIED, show_alert=True) return try: await callback.message.delete() finally: with contextlib.suppress(Exception): - await callback.answer("Сообщение удалено") + await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено")) def register_handlers(dp: Dispatcher): diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 720b26f5..a93409d3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -564,5 +564,63 @@ "USER_BLOCKED_FOREVER": "You are blocked from contacting support.", "USER_BLOCKED_UNTIL": "You are blocked until {time}", "VIEW_CLOSED_TICKETS": "🟢 Closed tickets", - "VIEW_TICKET": "👁️ View ticket" + "VIEW_TICKET": "👁️ View ticket", + "ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n", + "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:", + "ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:", + "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.", + "ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ Support moderation", + "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.", + "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Moderator audit", + "ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet", + "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator", + "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block", + "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock", + "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n", + "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:", + "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n", + "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:", + "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled", + "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled", + "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item", + "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets", + "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact", + "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both", + "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description", + "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications", + "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications", + "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA", + "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min", + "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Moderators: {count}", + "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator", + "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator", + "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Support settings", + "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:", + "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ SLA configuration\n\nEnter the response wait time in minutes (integer > 0):", + "ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)", + "ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved", + "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Assign moderator\n\nSend the user's Telegram ID (number)", + "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ Remove moderator\n\nSend the user's Telegram ID (number)", + "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)", + "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed", + "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator", + "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator", + "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator", + "ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty", + "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ Moderators", + "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Editing support description", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Contact for \"Contact\" mode", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.", + "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.", + "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below", + "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index b098b064..64bf1bc1 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -564,5 +564,63 @@ "USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.", "USER_BLOCKED_UNTIL": "Вы заблокированы до {time}", "VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты", - "VIEW_TICKET": "👁️ Посмотреть тикет" + "VIEW_TICKET": "👁️ Посмотреть тикет", + "ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n", + "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:", + "ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:", + "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.", + "ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ Модерация поддержки", + "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.", + "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Аудит модераторов", + "ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто", + "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор", + "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)", + "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока", + "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n", + "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:", + "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n", + "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:", + "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены", + "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены", + "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню", + "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты", + "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт", + "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба", + "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание", + "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления", + "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления", + "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA", + "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин", + "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Модераторы: {count}", + "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора", + "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора", + "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Настройки поддержки", + "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:", + "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + "ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)", + "ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено", + "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)", + "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён", + "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора", + "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором", + "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора", + "ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст", + "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ Модераторы", + "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Редактирование описания поддержки", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Контакт для режима «Контакт»", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.", + "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.", + "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже", + "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено" } diff --git a/locales/en.json b/locales/en.json index 63729b65..e94449be 100644 --- a/locales/en.json +++ b/locales/en.json @@ -548,11 +548,69 @@ "ADMIN_MAIN_MESSAGES": "📨 Messages", "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n", + "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:", + "ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:", "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message", "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages", "ADMIN_SUPPORT_TICKETS": "🎫 Support tickets", "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit", "ADMIN_SUPPORT_SETTINGS": "🛟 Support settings", + "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled", + "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled", + "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item", + "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets", + "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact", + "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both", + "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description", + "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications", + "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications", + "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA", + "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min", + "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Moderators: {count}", + "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator", + "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator", + "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Support settings", + "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:", + "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ SLA configuration\n\nEnter the response wait time in minutes (integer > 0):", + "ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)", + "ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved", + "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Assign moderator\n\nSend the user's Telegram ID (number)", + "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ Remove moderator\n\nSend the user's Telegram ID (number)", + "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)", + "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed", + "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator", + "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator", + "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator", + "ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty", + "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ Moderators", + "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Editing support description", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Contact for \"Contact\" mode", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.", + "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.", + "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below", + "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted", + "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.", + "ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ Support moderation", + "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.", + "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Moderator audit", + "ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet", + "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator", + "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block", + "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock", + "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n", + "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:", + "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n", + "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:", "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration", "ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance", "ADMIN_SYSTEM_UPDATES": "📄 Updates", diff --git a/locales/ru.json b/locales/ru.json index 33a4bf26..29fe6c3c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -548,11 +548,69 @@ "ADMIN_MAIN_MESSAGES": "📨 Сообщения", "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n", + "ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n", + "ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:", + "ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:", "ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст", "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню", "ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки", "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов", "ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки", + "ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены", + "ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены", + "ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню", + "ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты", + "ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт", + "ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба", + "ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание", + "ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления", + "ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления", + "ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA", + "ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин", + "ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Модераторы: {count}", + "ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора", + "ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора", + "ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 Настройки поддержки", + "ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:", + "ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):", + "ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)", + "ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено", + "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)", + "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)", + "ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)", + "ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён", + "ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора", + "ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором", + "ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора", + "ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст", + "ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ Модераторы", + "ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 Редактирование описания поддержки", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "Контакт для режима «Контакт»", + "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.", + "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.", + "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже", + "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено", + "ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:", + "ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.", + "ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ Модерация поддержки", + "ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.", + "ADMIN_SUPPORT_AUDIT_TITLE": "🧾 Аудит модераторов", + "ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто", + "ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор", + "ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ", + "ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)", + "ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)", + "ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока", + "ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n", + "ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:", + "ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n", + "ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:", "ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота", "ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы", "ADMIN_SYSTEM_UPDATES": "📄 Обновления", From efd4eda62aad76022835268002eb905596bff27a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 01:04:05 +0300 Subject: [PATCH 17/24] Add English localization for admin user view --- app/handlers/admin/users.py | 124 +++++++++++++++++++------------ app/localization/locales/en.json | 16 ++++ app/localization/locales/ru.json | 16 ++++ app/utils/formatters.py | 69 ++++++++++------- locales/en.json | 16 ++++ locales/ru.json | 16 ++++ 6 files changed, 183 insertions(+), 74 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index dcd29959..1cb146f7 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -142,7 +142,7 @@ async def show_users_list( if user.balance_kopeks > 0: button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" - button_text += f" | 📅 {format_time_ago(user.created_at)}" + button_text += f" | 📅 {format_time_ago(user.created_at, db_user.language)}" if len(button_text) > 60: short_name = user.full_name @@ -433,7 +433,7 @@ async def show_users_list_by_last_activity( status_emoji = "🗑️" activity_display = ( - format_time_ago(user.last_activity) + format_time_ago(user.last_activity, db_user.language) if user.last_activity else "неизвестно" ) @@ -1373,61 +1373,82 @@ async def show_user_management( user = profile["user"] subscription = profile["subscription"] - - if user.status == UserStatus.ACTIVE.value: - status_text = "✅ Активен" - elif user.status == UserStatus.BLOCKED.value: - status_text = "🚫 Заблокирован" - elif user.status == UserStatus.DELETED.value: - status_text = "🗑️ Удален" - else: - status_text = "❓ Неизвестно" - - text = f""" -👤 Управление пользователем -Основная информация: -• Имя: {user.full_name} -• ID: {user.telegram_id} -• Username: @{user.username or 'не указан'} -• Статус: {status_text} -• Язык: {user.language} + texts = get_texts(db_user.language) -Финансы: -• Баланс: {settings.format_price(user.balance_kopeks)} -• Транзакций: {profile['transactions_count']} + status_map = { + UserStatus.ACTIVE.value: texts.ADMIN_USER_STATUS_ACTIVE, + UserStatus.BLOCKED.value: texts.ADMIN_USER_STATUS_BLOCKED, + UserStatus.DELETED.value: texts.ADMIN_USER_STATUS_DELETED, + } + status_text = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN) + + username_display = ( + f"@{user.username}" if user.username else texts.ADMIN_USER_USERNAME_NOT_SET + ) + last_activity = ( + format_time_ago(user.last_activity, db_user.language) + if user.last_activity + else texts.ADMIN_USER_LAST_ACTIVITY_UNKNOWN + ) + + sections = [ + texts.ADMIN_USER_MANAGEMENT_PROFILE.format( + name=user.full_name, + telegram_id=user.telegram_id, + username=username_display, + status=status_text, + language=user.language, + balance=settings.format_price(user.balance_kopeks), + transactions=profile["transactions_count"], + registration=format_datetime(user.created_at), + last_activity=last_activity, + registration_days=profile["registration_days"], + ) + ] -Активность: -• Регистрация: {format_datetime(user.created_at)} -• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'} -• Дней с регистрации: {profile['registration_days']} -""" - if subscription: - text += f""" -Подписка: -• Тип: {'🎁 Триал' if subscription.is_trial else '💎 Платная'} -• Статус: {'✅ Активна' if subscription.is_active else '❌ Неактивна'} -• До: {format_datetime(subscription.end_date)} -• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ -• Устройства: {subscription.device_limit} -• Стран: {len(subscription.connected_squads)} -""" + subscription_type = ( + texts.ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL + if subscription.is_trial + else texts.ADMIN_USER_SUBSCRIPTION_TYPE_PAID + ) + subscription_status = ( + texts.ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE + if subscription.is_active + else texts.ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE + ) + traffic_usage = texts.ADMIN_USER_TRAFFIC_USAGE.format( + used=f"{subscription.traffic_used_gb:.1f}", + limit=subscription.traffic_limit_gb, + ) + sections.append( + texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION.format( + type=subscription_type, + status=subscription_status, + end_date=format_datetime(subscription.end_date), + traffic=traffic_usage, + devices=subscription.device_limit, + countries=len(subscription.connected_squads), + ) + ) else: - text += "\nПодписка: Отсутствует" + sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE) if user.promo_group: promo_group = user.promo_group - text += f""" - -Промогруппа: -• Название: {promo_group.name} -• Скидка на сервера: {promo_group.server_discount_percent}% -• Скидка на трафик: {promo_group.traffic_discount_percent}% -• Скидка на устройства: {promo_group.device_discount_percent}% -""" + sections.append( + texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format( + name=promo_group.name, + server_discount=promo_group.server_discount_percent, + traffic_discount=promo_group.traffic_discount_percent, + device_discount=promo_group.device_discount_percent, + ) + ) else: - text += "\nПромогруппа: Не назначена" + sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE) + + text = "\n\n".join(sections) # Проверяем состояние, чтобы определить, откуда пришел пользователь current_state = await state.get_state() @@ -1763,7 +1784,12 @@ async def show_inactive_users( for user in inactive_users[:10]: text += f"👤 {user.full_name}\n" text += f"🆔 {user.telegram_id}\n" - text += f"📅 {format_time_ago(user.last_activity) if user.last_activity else 'Никогда'}\n\n" + last_activity_display = ( + format_time_ago(user.last_activity, db_user.language) + if user.last_activity + else "Никогда" + ) + text += f"📅 {last_activity_display}\n\n" if len(inactive_users) > 10: text += f"... и еще {len(inactive_users) - 10} пользователей" diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index a93409d3..2cd0fec1 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -163,6 +163,22 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", + "ADMIN_USER_MANAGEMENT_PROFILE": "👤 User management\n\nMain information:\n• Name: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\nFinances:\n• Balance: {balance}\n• Transactions: {transactions}\n\nActivity:\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}", + "ADMIN_USER_USERNAME_NOT_SET": "not set", + "ADMIN_USER_STATUS_ACTIVE": "✅ Active", + "ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked", + "ADMIN_USER_STATUS_DELETED": "🗑️ Deleted", + "ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown", + "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown", + "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial", + "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid", + "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active", + "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive", + "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Subscription:\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 64bf1bc1..3dabdd53 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -38,6 +38,22 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.", "ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю", + "ADMIN_USER_MANAGEMENT_PROFILE": "👤 Управление пользователем\n\nОсновная информация:\n• Имя: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\nФинансы:\n• Баланс: {balance}\n• Транзакций: {transactions}\n\nАктивность:\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}", + "ADMIN_USER_USERNAME_NOT_SET": "не указан", + "ADMIN_USER_STATUS_ACTIVE": "✅ Активен", + "ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован", + "ADMIN_USER_STATUS_DELETED": "🗑️ Удален", + "ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно", + "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно", + "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал", + "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная", + "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна", + "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна", + "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Подписка:\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена", "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", diff --git a/app/utils/formatters.py b/app/utils/formatters.py index 07b3dc74..c98d2f65 100644 --- a/app/utils/formatters.py +++ b/app/utils/formatters.py @@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str: return dt.strftime(format_str) -def format_time_ago(dt: Union[datetime, str]) -> str: +def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str: if isinstance(dt, str): if dt == "now" or dt == "": dt = datetime.now() @@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str: now = datetime.utcnow() diff = now - dt - + + language_code = (language or "ru").split("-")[0].lower() + if diff.days > 0: if diff.days == 1: - return "вчера" - elif diff.days < 7: - return f"{diff.days} дн. назад" - elif diff.days < 30: - weeks = diff.days // 7 - return f"{weeks} нед. назад" - elif diff.days < 365: - months = diff.days // 30 - return f"{months} мес. назад" - else: - years = diff.days // 365 - return f"{years} г. назад" - - elif diff.seconds > 3600: - hours = diff.seconds // 3600 - return f"{hours} ч. назад" - - elif diff.seconds > 60: - minutes = diff.seconds // 60 - return f"{minutes} мин. назад" - - else: - return "только что" + return "yesterday" if language_code == "en" else "вчера" + if diff.days < 7: + value = diff.days + if language_code == "en": + suffix = "day" if value == 1 else "days" + return f"{value} {suffix} ago" + return f"{value} дн. назад" + if diff.days < 30: + value = diff.days // 7 + if language_code == "en": + suffix = "week" if value == 1 else "weeks" + return f"{value} {suffix} ago" + return f"{value} нед. назад" + if diff.days < 365: + value = diff.days // 30 + if language_code == "en": + suffix = "month" if value == 1 else "months" + return f"{value} {suffix} ago" + return f"{value} мес. назад" + value = diff.days // 365 + if language_code == "en": + suffix = "year" if value == 1 else "years" + return f"{value} {suffix} ago" + return f"{value} г. назад" + + if diff.seconds > 3600: + value = diff.seconds // 3600 + if language_code == "en": + suffix = "hour" if value == 1 else "hours" + return f"{value} {suffix} ago" + return f"{value} ч. назад" + + if diff.seconds > 60: + value = diff.seconds // 60 + if language_code == "en": + suffix = "minute" if value == 1 else "minutes" + return f"{value} {suffix} ago" + return f"{value} мин. назад" + + return "just now" if language_code == "en" else "только что" def format_days_declension(days: int, language: str = "ru") -> str: if language != "ru": diff --git a/locales/en.json b/locales/en.json index e94449be..d970b566 100644 --- a/locales/en.json +++ b/locales/en.json @@ -685,6 +685,22 @@ "ADMIN_USER_DELETE": "🗑️ Delete", "ADMIN_USER_UNBLOCK": "✅ Unblock", "ADMIN_USER_ALREADY_DELETED": "❌ User deleted", + "ADMIN_USER_MANAGEMENT_PROFILE": "👤 User management\n\nMain information:\n• Name: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\nFinances:\n• Balance: {balance}\n• Transactions: {transactions}\n\nActivity:\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}", + "ADMIN_USER_USERNAME_NOT_SET": "not set", + "ADMIN_USER_STATUS_ACTIVE": "✅ Active", + "ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked", + "ADMIN_USER_STATUS_DELETED": "🗑️ Deleted", + "ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown", + "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown", + "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial", + "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid", + "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active", + "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive", + "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Subscription:\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned", "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial", diff --git a/locales/ru.json b/locales/ru.json index 29fe6c3c..13c5c434 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -685,6 +685,22 @@ "ADMIN_USER_DELETE": "🗑️ Удалить", "ADMIN_USER_UNBLOCK": "✅ Разблокировать", "ADMIN_USER_ALREADY_DELETED": "❌ Пользователь удален", + "ADMIN_USER_MANAGEMENT_PROFILE": "👤 Управление пользователем\n\nОсновная информация:\n• Имя: {name}\n• ID: {telegram_id}\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\nФинансы:\n• Баланс: {balance}\n• Транзакций: {transactions}\n\nАктивность:\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}", + "ADMIN_USER_USERNAME_NOT_SET": "не указан", + "ADMIN_USER_STATUS_ACTIVE": "✅ Активен", + "ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован", + "ADMIN_USER_STATUS_DELETED": "🗑️ Удален", + "ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно", + "ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно", + "ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал", + "ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная", + "ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна", + "ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна", + "ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "Подписка:\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}", + "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%", + "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена", "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал", From 359cdd106d766f6f36a8b1d1b04ca8e8b43148cb Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 01:17:43 +0300 Subject: [PATCH 18/24] Add English localization for subscription management menus --- app/handlers/subscription.py | 426 ++++++++++++++++++++++++------- app/localization/locales/en.json | 54 +++- app/localization/locales/ru.json | 54 +++- locales/en.json | 54 +++- locales/ru.json | 54 +++- 5 files changed, 540 insertions(+), 102 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 923a07ca..ac9353d5 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1174,7 +1174,10 @@ async def handle_add_countries( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return countries = await _get_available_countries(db_user.promo_group_id) @@ -1192,19 +1195,34 @@ async def handle_add_countries( if country['uuid'] in current_countries: current_countries_names.append(country['name']) - text = "🌍 Управление странами подписки\n\n" - text += f"📋 Текущие страны ({len(current_countries)}):\n" - if current_countries_names: - text += "\n".join(f"• {name}" for name in current_countries_names) - else: - text += "Нет подключенных стран" + countries_list = "\n".join(f"• {name}" for name in current_countries_names) + if not countries_list: + countries_list = texts.t("SUBSCRIPTION_COUNTRIES_NONE", "Нет подключенных стран") - text += "\n\n💡 Инструкция:\n" - text += "✅ - страна подключена\n" - text += "➕ - будет добавлена (платно)\n" - text += "➖ - будет отключена (бесплатно)\n" - text += "⚪ - не выбрана\n\n" - text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + text = "\n\n".join( + [ + texts.t( + "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE", + "🌍 Управление странами подписки", + ), + texts.t( + "SUBSCRIPTION_COUNTRIES_CURRENT", + "📋 Текущие страны ({count}):\n{countries}", + ).format(count=len(current_countries), countries=countries_list), + texts.t( + "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS", + "💡 Инструкция:\n" + "✅ - страна подключена\n" + "➕ - будет добавлена (платно)\n" + "➖ - будет отключена (бесплатно)\n" + "⚪ - не выбрана", + ), + texts.t( + "SUBSCRIPTION_COUNTRIES_NOTICE", + "⚠️ Важно: Повторное подключение отключенных стран будет платным!", + ), + ] + ) await state.update_data(countries=current_countries.copy()) @@ -1274,9 +1292,13 @@ async def handle_manage_country( country_uuid = callback.data.split('_')[2] + texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠ Только для платных подписок"), + show_alert=True, + ) return data = await state.get_data() @@ -1286,7 +1308,13 @@ async def handle_manage_country( allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in current_selected: - await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE", + "❌ Сервер недоступен для вашей промогруппы", + ), + show_alert=True, + ) return if country_uuid in current_selected: @@ -1598,7 +1626,10 @@ async def handle_change_devices( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return current_devices = subscription.device_limit @@ -1610,13 +1641,32 @@ async def handle_change_devices( period_hint_days, ) + texts = get_texts(db_user.language) + await callback.message.edit_text( - f"📱 Изменение количества устройств\n\n" - f"Текущий лимит: {current_devices} устройств\n" - f"Выберите новое количество устройств:\n\n" - f"💡 Важно:\n" - f"• При увеличении - доплата пропорционально оставшемуся времени\n" - f"• При уменьшении - возврат средств не производится", + "\n".join( + [ + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_TITLE", + "📱 Изменение количества устройств", + ), + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_CURRENT", + "Текущий лимит: {count} устройств", + ).format(count=current_devices), + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_PROMPT", + "Выберите новое количество устройств:", + ), + "", + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT", + "💡 Важно:\n" + "• При увеличении - доплата пропорционально оставшемуся времени\n" + "• При уменьшении - возврат средств не производится", + ), + ] + ), reply_markup=get_change_devices_keyboard( current_devices, db_user.language, @@ -1641,13 +1691,22 @@ async def confirm_change_devices( current_devices = subscription.device_limit if new_devices_count == current_devices: - await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE", + "ℹ️ Количество устройств не изменилось", + ), + show_alert=True, + ) return if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( - f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})", - show_alert=True + texts.t( + "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT", + "⚠️ Превышен максимальный лимит устройств ({limit})", + ).format(limit=settings.MAX_DEVICES_LIMIT), + show_alert=True, ) return @@ -1831,11 +1890,17 @@ async def handle_device_management( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return if not db_user.remnawave_uuid: - await callback.answer("❌ UUID пользователя не найден", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_DEVICES_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), + show_alert=True, + ) return try: @@ -1852,7 +1917,10 @@ async def handle_device_management( if total_devices == 0: await callback.message.edit_text( - "ℹ️ У вас нет подключенных устройств", + texts.t( + "SUBSCRIPTION_DEVICES_NONE", + "ℹ️ У вас нет подключенных устройств", + ), reply_markup=get_back_keyboard(db_user.language) ) await callback.answer() @@ -1860,11 +1928,23 @@ async def handle_device_management( await show_devices_page(callback, db_user, devices_list, page=1) else: - await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_DEVICES_FETCH_ERROR", + "❌ Ошибка получения информации об устройствах", + ), + show_alert=True, + ) except Exception as e: logger.error(f"Ошибка получения списка устройств: {e}") - await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_DEVICES_FETCH_ERROR", + "❌ Ошибка получения информации об устройствах", + ), + show_alert=True, + ) await callback.answer() @@ -1880,15 +1960,32 @@ async def show_devices_page( pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - devices_text = f"🔄 Управление устройствами\n\n" - devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n" - devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n" + devices_text = "\n".join( + [ + texts.t( + "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE", + "🔄 Управление устройствами", + ), + texts.t( + "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL", + "📊 Всего подключено: {count} устройств", + ).format(count=len(devices_list)), + texts.t( + "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE", + "📄 Страница {current} из {total}", + ).format(current=pagination.page, total=pagination.total_pages), + "", + ] + ) if pagination.items: - devices_text += "Подключенные устройства:\n" + devices_text += texts.t( + "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER", + "Подключенные устройства:", + ) + "\n" for i, device in enumerate(pagination.items, 1): - platform = device.get('platform', 'Unknown') - device_model = device.get('deviceModel', 'Unknown') + platform = device.get('platform', texts.t("SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM", "Unknown")) + device_model = device.get('deviceModel', texts.t("SUBSCRIPTION_DEVICES_UNKNOWN_MODEL", "Unknown")) device_info = f"{platform} - {device_model}" if len(device_info) > 35: @@ -1896,9 +1993,12 @@ async def show_devices_page( devices_text += f"• {device_info}\n" - devices_text += "\n💡 Действия:\n" - devices_text += "• Выберите устройство для сброса\n" - devices_text += "• Или сбросьте все устройства сразу" + devices_text += "\n" + texts.t( + "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS", + "💡 Действия:\n" + "• Выберите устройство для сброса\n" + "• Или сбросьте все устройства сразу", + ) await callback.message.edit_text( devices_text, @@ -3926,21 +4026,31 @@ async def handle_subscription_settings( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT", "⚠️ Настройки доступны только для платных подписок"), + show_alert=True, + ) return devices_used = await get_current_devices_count(db_user) - settings_text = f""" -⚙️ Настройки подписки - -📊 Текущие параметры: -🌐 Стран: {len(subscription.connected_squads)} -📈 Трафик: {texts.format_traffic(subscription.traffic_used_gb)} / {texts.format_traffic(subscription.traffic_limit_gb)} -📱 Устройства: {devices_used} / {subscription.device_limit} - -Выберите что хотите изменить: -""" + traffic_usage = ( + f"{texts.format_traffic(subscription.traffic_used_gb)} / " + f"{texts.format_traffic(subscription.traffic_limit_gb)}" + ) + settings_text = texts.t( + "SUBSCRIPTION_SETTINGS_OVERVIEW", + "⚙️ Настройки подписки\n\n" + "📊 Текущие параметры:\n" + "🌐 Стран: {countries}\n" + "📈 Трафик: {traffic}\n" + "📱 Устройства: {devices}\n\n" + "Выберите что хотите изменить:", + ).format( + countries=len(subscription.connected_squads), + traffic=traffic_usage, + devices=f"{devices_used} / {subscription.device_limit}", + ) show_countries = await _should_show_countries_management(db_user) @@ -3957,18 +4067,28 @@ async def handle_autopay_menu( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription: - await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION", "⚠️ У вас нет активной подписки!"), + show_alert=True, + ) return - status = "включен" if subscription.autopay_enabled else "выключен" + status = texts.t( + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED" if subscription.autopay_enabled else "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED", + "включен" if subscription.autopay_enabled else "выключен", + ) days = subscription.autopay_days_before - text = f"💳 Автоплатеж\n\n" - text += f"📊 Статус: {status}\n" - text += f"⏰ Списание за: {days} дн. до окончания\n\n" - text += "Выберите действие:" + text = texts.t( + "SUBSCRIPTION_AUTOPAY_MENU", + "💳 Автоплатеж\n\n" + "📊 Статус: {status}\n" + "⏰ Списание за: {days} дн. до окончания\n\n" + "Выберите действие:", + ).format(status=status, days=days) await callback.message.edit_text( text, @@ -3982,13 +4102,19 @@ async def toggle_autopay( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) subscription = db_user.subscription enable = callback.data == "autopay_enable" await update_subscription_autopay(db, subscription, enable) - status = "включен" if enable else "выключен" - await callback.answer(f"✅ Автоплатеж {status}!") + status = texts.t( + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED" if enable else "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED", + "включен" if enable else "выключен", + ) + await callback.answer( + texts.t("SUBSCRIPTION_AUTOPAY_TOGGLED", "✅ Автоплатеж {status}!").format(status=status) + ) await handle_autopay_menu(callback, db_user, db) @@ -3997,8 +4123,12 @@ async def show_autopay_days( callback: types.CallbackQuery, db_user: User ): + texts = get_texts(db_user.language) await callback.message.edit_text( - "⏰ Выберите за сколько дней до окончания списывать средства:", + texts.t( + "SUBSCRIPTION_AUTOPAY_SELECT_DAYS", + "⏰ Выберите за сколько дней до окончания списывать средства:", + ), reply_markup=get_autopay_days_keyboard(db_user.language) ) await callback.answer() @@ -4012,11 +4142,15 @@ async def set_autopay_days( days = int(callback.data.split('_')[2]) subscription = db_user.subscription + texts = get_texts(db_user.language) + await update_subscription_autopay( db, subscription, subscription.autopay_enabled, days ) - await callback.answer(f"✅ Установлено {days} дней!") + await callback.answer( + texts.t("SUBSCRIPTION_AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days) + ) await handle_autopay_menu(callback, db_user, db) @@ -5310,15 +5444,22 @@ async def handle_switch_traffic( ): from app.config import settings + texts = get_texts(db_user.language) + if settings.is_traffic_fixed(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_TRAFFIC_FIXED_ALERT", "⚠️ В текущем режиме трафик фиксированный"), + show_alert=True, + ) return - texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return current_traffic = subscription.traffic_limit_gb @@ -5330,12 +5471,29 @@ async def handle_switch_traffic( ) await callback.message.edit_text( - f"🔄 Переключение лимита трафика\n\n" - f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" - f"Выберите новый лимит трафика:\n\n" - f"💡 Важно:\n" - f"• При увеличении - доплата за разницу\n" - f"• При уменьшении - возврат средств не производится", + "\n".join( + [ + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE", + "🔄 Переключение лимита трафика", + ), + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT", + "Текущий лимит: {traffic}", + ).format(traffic=texts.format_traffic(current_traffic)), + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT", + "Выберите новый лимит трафика:", + ), + "", + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT", + "💡 Важно:\n" + "• При увеличении - доплата за разницу\n" + "• При уменьшении - возврат средств не производится", + ), + ] + ), reply_markup=get_traffic_switch_keyboard( current_traffic, db_user.language, @@ -5360,7 +5518,13 @@ async def confirm_switch_traffic( current_traffic = subscription.traffic_limit_gb if new_traffic_gb == current_traffic: - await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE", + "ℹ️ Лимит трафика не изменился", + ), + show_alert=True, + ) return old_price_per_month = settings.get_traffic_price(current_traffic) @@ -5482,7 +5646,13 @@ async def execute_switch_traffic( ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR", + "⚠️ Ошибка списания средств", + ), + show_alert=True, + ) return months_remaining = get_remaining_months(subscription.end_date) @@ -5514,17 +5684,49 @@ async def execute_switch_traffic( except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}") + change_line = texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE", + "📊 Было: {old} → Стало: {new}", + ).format( + old=texts.format_traffic(current_traffic), + new=texts.format_traffic(new_traffic_gb), + ) + if new_traffic_gb > current_traffic: - success_text = f"✅ Лимит трафика увеличен!\n\n" - success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " - success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" + success_parts = [ + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE", + "✅ Лимит трафика увеличен!", + ), + "", + change_line, + ] if price_difference > 0: - success_text += f"💰 Списано: {texts.format_price(price_difference)}" - elif new_traffic_gb < current_traffic: - success_text = f"✅ Лимит трафика уменьшен!\n\n" - success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " - success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" - success_text += f"ℹ️ Возврат средств не производится" + success_parts.extend( + [ + "", + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED", + "💰 Списано: {amount}", + ).format(amount=texts.format_price(price_difference)), + ] + ) + else: + success_parts = [ + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE", + "✅ Лимит трафика уменьшен!", + ), + "", + change_line, + "", + texts.t( + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND", + "ℹ️ Возврат средств не производится", + ), + ] + + success_text = "\n".join(part for part in success_parts if part is not None) await callback.message.edit_text( success_text, @@ -5552,12 +5754,16 @@ def get_traffic_switch_keyboard( ) -> InlineKeyboardMarkup: from app.config import settings + texts = get_texts(language) months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = f" (за {months_multiplier} мес)" + period_text = texts.t( + "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX", + " за {months} мес", + ).format(months=months_multiplier) packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] @@ -5583,34 +5789,57 @@ def get_traffic_switch_keyboard( if gb == current_traffic_gb: emoji = "✅" - action_text = " (текущий)" - price_text = "" + suffix = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT", + " (текущий)", + ) elif total_price_diff > 0: emoji = "⬆️" - action_text = "" - price_text = f" (+{total_price_diff // 100}₽{period_text})" + suffix = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP", + " (+{amount}{period})", + ).format( + amount=texts.format_price(total_price_diff), + period=period_text, + ) if discount_percent > 0: discount_total = ( - (price_per_month - current_price_per_month) * months_multiplier - - total_price_diff + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff ) if discount_total > 0: - price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)" + suffix += texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT", + " (скидка {percent}%: -{amount})", + ).format( + percent=discount_percent, + amount=texts.format_price(discount_total), + ) elif total_price_diff < 0: emoji = "⬇️" - action_text = "" - price_text = " (без возврата)" + suffix = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND", + " (без возврата)", + ) else: emoji = "🔄" - action_text = "" - price_text = " (бесплатно)" + suffix = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_FREE", + " (бесплатно)", + ) if gb == 0: - traffic_text = "Безлимит" + traffic_text = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED", + "Безлимит", + ) else: - traffic_text = f"{gb} ГБ" + traffic_text = texts.t( + "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED", + "{gb} ГБ", + ).format(gb=gb) - button_text = f"{emoji} {traffic_text}{action_text}{price_text}" + button_text = f"{emoji} {traffic_text}{suffix}" buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}") @@ -5618,7 +5847,7 @@ def get_traffic_switch_keyboard( buttons.append([ InlineKeyboardButton( - text="⬅️ Назад" if language == "ru" else "⬅️ Back", + text=texts.BACK, callback_data="subscription_settings" ) ]) @@ -5631,16 +5860,17 @@ def get_confirm_switch_traffic_keyboard( price_difference: int, language: str = "ru" ) -> InlineKeyboardMarkup: + texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( - text="✅ Подтвердить переключение", + text=texts.CONFIRM_CHANGE_BUTTON, callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}" ) ], [ InlineKeyboardButton( - text="❌ Отмена", + text=texts.CANCEL, callback_data="subscription_settings" ) ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 2cd0fec1..07019749 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -638,5 +638,57 @@ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.", "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.", "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below", - "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted" + "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted", + "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Settings are available only for paid subscriptions", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries}\n📈 Traffic: {traffic}\n📱 Devices: {devices}\n\nChoose what you want to change:", + "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ You don't have an active subscription!", + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "enabled", + "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "disabled", + "SUBSCRIPTION_AUTOPAY_MENU": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiration\n\nChoose an action:", + "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Auto payment {status}!", + "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Choose how many days before expiration to charge:", + "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Set to {days} days!", + "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠️ This feature is only available for paid subscriptions", + "SUBSCRIPTION_COUNTRIES_NONE": "No connected countries", + "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Manage subscription countries", + "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Current countries ({count}):\n{countries}", + "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 How it works:\n✅ - country connected\n➕ - will be added (paid)\n➖ - will be removed (free)\n⚪️ - not selected", + "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Important: Reconnecting removed countries will incur an additional charge!", + "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ This server is not available for your promo group", + "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Change device limit", + "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Current limit: {count} devices", + "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Choose a new device limit:", + "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Important:\n• Increasing — additional charge proportional to the remaining time\n• Decreasing — no refunds", + "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ The number of devices hasn't changed", + "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Maximum device limit exceeded ({limit})", + "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ User UUID not found", + "SUBSCRIPTION_DEVICES_NONE": "ℹ️ You don't have any connected devices", + "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Failed to retrieve device information", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Manage devices", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Total connected: {count} devices", + "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Page {current} of {total}", + "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Connected devices:", + "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Unknown", + "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Unknown", + "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Actions:\n• Select a device to reset\n• Or reset all devices at once", + "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ Traffic is fixed in the current mode", + "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Current limit: {traffic}", + "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Choose a new traffic limit:", + "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Important:\n• Increasing — pay the difference\n• Decreasing — no refunds", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ The traffic limit hasn't changed", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Failed to charge funds", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Previously: {old} → Now: {new}", + "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Traffic limit increased!", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Charged: {amount}", + "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Traffic limit decreased!", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Refunds are not provided", + "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " for {months} mo.", + "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (current)", + "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", + "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (discount {percent}%: -{amount})", + "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (no refund)", + "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (free)", + "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Unlimited", + "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} GB" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 3dabdd53..3c62264c 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -638,5 +638,57 @@ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.", "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.", "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже", - "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено" + "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено", + "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Настройки доступны только для платных подписок", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries}\n📈 Трафик: {traffic}\n📱 Устройства: {devices}\n\nВыберите что хотите изменить:", + "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ У вас нет активной подписки!", + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "включен", + "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "выключен", + "SUBSCRIPTION_AUTOPAY_MENU": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:", + "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Автоплатеж {status}!", + "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Выберите за сколько дней до окончания списывать средства:", + "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!", + "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠ Эта функция доступна только для платных подписок", + "SUBSCRIPTION_COUNTRIES_NONE": "Нет подключенных стран", + "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Управление странами подписки", + "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Текущие страны ({count}):\n{countries}", + "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪️ - не выбрана", + "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Важно: Повторное подключение отключенных стран будет платным!", + "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ Сервер недоступен для вашей промогруппы", + "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Текущий лимит: {count} устройств", + "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Выберите новое количество устройств:", + "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится", + "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Превышен максимальный лимит устройств ({limit})", + "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ UUID пользователя не найден", + "SUBSCRIPTION_DEVICES_NONE": "ℹ️ У вас нет подключенных устройств", + "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Ошибка получения информации об устройствах", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Управление устройствами", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Всего подключено: {count} устройств", + "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Страница {current} из {total}", + "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Подключенные устройства:", + "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Неизвестно", + "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Неизвестно", + "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу", + "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ В текущем режиме трафик фиксированный", + "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", + "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Текущий лимит: {traffic}", + "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Выберите новый лимит трафика:", + "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Важно:\n• При увеличении - доплата за разницу\n• При уменьшении - возврат средств не производится", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Было: {old} → Стало: {new}", + "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Лимит трафика увеличен!", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Списано: {amount}", + "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Лимит трафика уменьшен!", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Возврат средств не производится", + "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " за {months} мес", + "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (текущий)", + "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", + "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (скидка {percent}%: -{amount})", + "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (без возврата)", + "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (бесплатно)", + "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Безлимит", + "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} ГБ" } diff --git a/locales/en.json b/locales/en.json index d970b566..c660b19d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -815,5 +815,57 @@ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect", "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription", "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support", - "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu" + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu", + "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Settings are available only for paid subscriptions", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries}\n📈 Traffic: {traffic}\n📱 Devices: {devices}\n\nChoose what you want to change:", + "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ You don't have an active subscription!", + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "enabled", + "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "disabled", + "SUBSCRIPTION_AUTOPAY_MENU": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiration\n\nChoose an action:", + "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Auto payment {status}!", + "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Choose how many days before expiration to charge:", + "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Set to {days} days!", + "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠️ This feature is only available for paid subscriptions", + "SUBSCRIPTION_COUNTRIES_NONE": "No connected countries", + "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Manage subscription countries", + "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Current countries ({count}):\n{countries}", + "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 How it works:\n✅ - country connected\n➕ - will be added (paid)\n➖ - will be removed (free)\n⚪️ - not selected", + "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Important: Reconnecting removed countries will incur an additional charge!", + "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ This server is not available for your promo group", + "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Change device limit", + "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Current limit: {count} devices", + "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Choose a new device limit:", + "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Important:\n• Increasing — additional charge proportional to the remaining time\n• Decreasing — no refunds", + "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ The number of devices hasn't changed", + "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Maximum device limit exceeded ({limit})", + "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ User UUID not found", + "SUBSCRIPTION_DEVICES_NONE": "ℹ️ You don't have any connected devices", + "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Failed to retrieve device information", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Manage devices", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Total connected: {count} devices", + "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Page {current} of {total}", + "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Connected devices:", + "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Unknown", + "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Unknown", + "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Actions:\n• Select a device to reset\n• Or reset all devices at once", + "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ Traffic is fixed in the current mode", + "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", + "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Current limit: {traffic}", + "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Choose a new traffic limit:", + "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Important:\n• Increasing — pay the difference\n• Decreasing — no refunds", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ The traffic limit hasn't changed", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Failed to charge funds", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Previously: {old} → Now: {new}", + "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Traffic limit increased!", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Charged: {amount}", + "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Traffic limit decreased!", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Refunds are not provided", + "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " for {months} mo.", + "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (current)", + "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", + "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (discount {percent}%: -{amount})", + "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (no refund)", + "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (free)", + "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Unlimited", + "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} GB" } diff --git a/locales/ru.json b/locales/ru.json index 13c5c434..ce189907 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -815,5 +815,57 @@ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться", "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка", "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка", - "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную" + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную", + "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Настройки доступны только для платных подписок", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries}\n📈 Трафик: {traffic}\n📱 Устройства: {devices}\n\nВыберите что хотите изменить:", + "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ У вас нет активной подписки!", + "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "включен", + "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "выключен", + "SUBSCRIPTION_AUTOPAY_MENU": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:", + "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Автоплатеж {status}!", + "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Выберите за сколько дней до окончания списывать средства:", + "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!", + "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠ Эта функция доступна только для платных подписок", + "SUBSCRIPTION_COUNTRIES_NONE": "Нет подключенных стран", + "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Управление странами подписки", + "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Текущие страны ({count}):\n{countries}", + "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪️ - не выбрана", + "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Важно: Повторное подключение отключенных стран будет платным!", + "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ Сервер недоступен для вашей промогруппы", + "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", + "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Текущий лимит: {count} устройств", + "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Выберите новое количество устройств:", + "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится", + "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Превышен максимальный лимит устройств ({limit})", + "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ UUID пользователя не найден", + "SUBSCRIPTION_DEVICES_NONE": "ℹ️ У вас нет подключенных устройств", + "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Ошибка получения информации об устройствах", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Управление устройствами", + "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Всего подключено: {count} устройств", + "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Страница {current} из {total}", + "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Подключенные устройства:", + "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Неизвестно", + "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Неизвестно", + "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу", + "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ В текущем режиме трафик фиксированный", + "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", + "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Текущий лимит: {traffic}", + "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Выберите новый лимит трафика:", + "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Важно:\n• При увеличении - доплата за разницу\n• При уменьшении - возврат средств не производится", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Было: {old} → Стало: {new}", + "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Лимит трафика увеличен!", + "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Списано: {amount}", + "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Лимит трафика уменьшен!", + "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Возврат средств не производится", + "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " за {months} мес", + "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (текущий)", + "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", + "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (скидка {percent}%: -{amount})", + "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (без возврата)", + "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (бесплатно)", + "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Безлимит", + "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} ГБ" } From 24e6d572732558693af1d87f2933d61d6674c8a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 01:22:28 +0300 Subject: [PATCH 19/24] Revert "Add English localization for subscription management interfaces" --- app/handlers/subscription.py | 426 +++++++------------------------ app/localization/locales/en.json | 54 +--- app/localization/locales/ru.json | 54 +--- locales/en.json | 54 +--- locales/ru.json | 54 +--- 5 files changed, 102 insertions(+), 540 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index ac9353d5..923a07ca 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1174,10 +1174,7 @@ async def handle_add_countries( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠ Эта функция доступна только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) return countries = await _get_available_countries(db_user.promo_group_id) @@ -1195,34 +1192,19 @@ async def handle_add_countries( if country['uuid'] in current_countries: current_countries_names.append(country['name']) - countries_list = "\n".join(f"• {name}" for name in current_countries_names) - if not countries_list: - countries_list = texts.t("SUBSCRIPTION_COUNTRIES_NONE", "Нет подключенных стран") + text = "🌍 Управление странами подписки\n\n" + text += f"📋 Текущие страны ({len(current_countries)}):\n" + if current_countries_names: + text += "\n".join(f"• {name}" for name in current_countries_names) + else: + text += "Нет подключенных стран" - text = "\n\n".join( - [ - texts.t( - "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE", - "🌍 Управление странами подписки", - ), - texts.t( - "SUBSCRIPTION_COUNTRIES_CURRENT", - "📋 Текущие страны ({count}):\n{countries}", - ).format(count=len(current_countries), countries=countries_list), - texts.t( - "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS", - "💡 Инструкция:\n" - "✅ - страна подключена\n" - "➕ - будет добавлена (платно)\n" - "➖ - будет отключена (бесплатно)\n" - "⚪ - не выбрана", - ), - texts.t( - "SUBSCRIPTION_COUNTRIES_NOTICE", - "⚠️ Важно: Повторное подключение отключенных стран будет платным!", - ), - ] - ) + text += "\n\n💡 Инструкция:\n" + text += "✅ - страна подключена\n" + text += "➕ - будет добавлена (платно)\n" + text += "➖ - будет отключена (бесплатно)\n" + text += "⚪ - не выбрана\n\n" + text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" await state.update_data(countries=current_countries.copy()) @@ -1292,13 +1274,9 @@ async def handle_manage_country( country_uuid = callback.data.split('_')[2] - texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠ Только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠ Только для платных подписок", show_alert=True) return data = await state.get_data() @@ -1308,13 +1286,7 @@ async def handle_manage_country( allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in current_selected: - await callback.answer( - texts.t( - "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE", - "❌ Сервер недоступен для вашей промогруппы", - ), - show_alert=True, - ) + await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return if country_uuid in current_selected: @@ -1626,10 +1598,7 @@ async def handle_change_devices( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return current_devices = subscription.device_limit @@ -1641,32 +1610,13 @@ async def handle_change_devices( period_hint_days, ) - texts = get_texts(db_user.language) - await callback.message.edit_text( - "\n".join( - [ - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_TITLE", - "📱 Изменение количества устройств", - ), - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_CURRENT", - "Текущий лимит: {count} устройств", - ).format(count=current_devices), - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_PROMPT", - "Выберите новое количество устройств:", - ), - "", - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT", - "💡 Важно:\n" - "• При увеличении - доплата пропорционально оставшемуся времени\n" - "• При уменьшении - возврат средств не производится", - ), - ] - ), + f"📱 Изменение количества устройств\n\n" + f"Текущий лимит: {current_devices} устройств\n" + f"Выберите новое количество устройств:\n\n" + f"💡 Важно:\n" + f"• При увеличении - доплата пропорционально оставшемуся времени\n" + f"• При уменьшении - возврат средств не производится", reply_markup=get_change_devices_keyboard( current_devices, db_user.language, @@ -1691,22 +1641,13 @@ async def confirm_change_devices( current_devices = subscription.device_limit if new_devices_count == current_devices: - await callback.answer( - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE", - "ℹ️ Количество устройств не изменилось", - ), - show_alert=True, - ) + await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True) return if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( - texts.t( - "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT", - "⚠️ Превышен максимальный лимит устройств ({limit})", - ).format(limit=settings.MAX_DEVICES_LIMIT), - show_alert=True, + f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})", + show_alert=True ) return @@ -1890,17 +1831,11 @@ async def handle_device_management( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return if not db_user.remnawave_uuid: - await callback.answer( - texts.t("SUBSCRIPTION_DEVICES_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), - show_alert=True, - ) + await callback.answer("❌ UUID пользователя не найден", show_alert=True) return try: @@ -1917,10 +1852,7 @@ async def handle_device_management( if total_devices == 0: await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_DEVICES_NONE", - "ℹ️ У вас нет подключенных устройств", - ), + "ℹ️ У вас нет подключенных устройств", reply_markup=get_back_keyboard(db_user.language) ) await callback.answer() @@ -1928,23 +1860,11 @@ async def handle_device_management( await show_devices_page(callback, db_user, devices_list, page=1) else: - await callback.answer( - texts.t( - "SUBSCRIPTION_DEVICES_FETCH_ERROR", - "❌ Ошибка получения информации об устройствах", - ), - show_alert=True, - ) + await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) except Exception as e: logger.error(f"Ошибка получения списка устройств: {e}") - await callback.answer( - texts.t( - "SUBSCRIPTION_DEVICES_FETCH_ERROR", - "❌ Ошибка получения информации об устройствах", - ), - show_alert=True, - ) + await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) await callback.answer() @@ -1960,32 +1880,15 @@ async def show_devices_page( pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - devices_text = "\n".join( - [ - texts.t( - "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE", - "🔄 Управление устройствами", - ), - texts.t( - "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL", - "📊 Всего подключено: {count} устройств", - ).format(count=len(devices_list)), - texts.t( - "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE", - "📄 Страница {current} из {total}", - ).format(current=pagination.page, total=pagination.total_pages), - "", - ] - ) + devices_text = f"🔄 Управление устройствами\n\n" + devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n" + devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n" if pagination.items: - devices_text += texts.t( - "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER", - "Подключенные устройства:", - ) + "\n" + devices_text += "Подключенные устройства:\n" for i, device in enumerate(pagination.items, 1): - platform = device.get('platform', texts.t("SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM", "Unknown")) - device_model = device.get('deviceModel', texts.t("SUBSCRIPTION_DEVICES_UNKNOWN_MODEL", "Unknown")) + platform = device.get('platform', 'Unknown') + device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" if len(device_info) > 35: @@ -1993,12 +1896,9 @@ async def show_devices_page( devices_text += f"• {device_info}\n" - devices_text += "\n" + texts.t( - "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS", - "💡 Действия:\n" - "• Выберите устройство для сброса\n" - "• Или сбросьте все устройства сразу", - ) + devices_text += "\n💡 Действия:\n" + devices_text += "• Выберите устройство для сброса\n" + devices_text += "• Или сбросьте все устройства сразу" await callback.message.edit_text( devices_text, @@ -4026,31 +3926,21 @@ async def handle_subscription_settings( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT", "⚠️ Настройки доступны только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True) return devices_used = await get_current_devices_count(db_user) - traffic_usage = ( - f"{texts.format_traffic(subscription.traffic_used_gb)} / " - f"{texts.format_traffic(subscription.traffic_limit_gb)}" - ) - settings_text = texts.t( - "SUBSCRIPTION_SETTINGS_OVERVIEW", - "⚙️ Настройки подписки\n\n" - "📊 Текущие параметры:\n" - "🌐 Стран: {countries}\n" - "📈 Трафик: {traffic}\n" - "📱 Устройства: {devices}\n\n" - "Выберите что хотите изменить:", - ).format( - countries=len(subscription.connected_squads), - traffic=traffic_usage, - devices=f"{devices_used} / {subscription.device_limit}", - ) + settings_text = f""" +⚙️ Настройки подписки + +📊 Текущие параметры: +🌐 Стран: {len(subscription.connected_squads)} +📈 Трафик: {texts.format_traffic(subscription.traffic_used_gb)} / {texts.format_traffic(subscription.traffic_limit_gb)} +📱 Устройства: {devices_used} / {subscription.device_limit} + +Выберите что хотите изменить: +""" show_countries = await _should_show_countries_management(db_user) @@ -4067,28 +3957,18 @@ async def handle_autopay_menu( db_user: User, db: AsyncSession ): - texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription: - await callback.answer( - texts.t("SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION", "⚠️ У вас нет активной подписки!"), - show_alert=True, - ) + await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) return - status = texts.t( - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED" if subscription.autopay_enabled else "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED", - "включен" if subscription.autopay_enabled else "выключен", - ) + status = "включен" if subscription.autopay_enabled else "выключен" days = subscription.autopay_days_before - text = texts.t( - "SUBSCRIPTION_AUTOPAY_MENU", - "💳 Автоплатеж\n\n" - "📊 Статус: {status}\n" - "⏰ Списание за: {days} дн. до окончания\n\n" - "Выберите действие:", - ).format(status=status, days=days) + text = f"💳 Автоплатеж\n\n" + text += f"📊 Статус: {status}\n" + text += f"⏰ Списание за: {days} дн. до окончания\n\n" + text += "Выберите действие:" await callback.message.edit_text( text, @@ -4102,19 +3982,13 @@ async def toggle_autopay( db_user: User, db: AsyncSession ): - texts = get_texts(db_user.language) subscription = db_user.subscription enable = callback.data == "autopay_enable" await update_subscription_autopay(db, subscription, enable) - status = texts.t( - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED" if enable else "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED", - "включен" if enable else "выключен", - ) - await callback.answer( - texts.t("SUBSCRIPTION_AUTOPAY_TOGGLED", "✅ Автоплатеж {status}!").format(status=status) - ) + status = "включен" if enable else "выключен" + await callback.answer(f"✅ Автоплатеж {status}!") await handle_autopay_menu(callback, db_user, db) @@ -4123,12 +3997,8 @@ async def show_autopay_days( callback: types.CallbackQuery, db_user: User ): - texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_AUTOPAY_SELECT_DAYS", - "⏰ Выберите за сколько дней до окончания списывать средства:", - ), + "⏰ Выберите за сколько дней до окончания списывать средства:", reply_markup=get_autopay_days_keyboard(db_user.language) ) await callback.answer() @@ -4142,15 +4012,11 @@ async def set_autopay_days( days = int(callback.data.split('_')[2]) subscription = db_user.subscription - texts = get_texts(db_user.language) - await update_subscription_autopay( db, subscription, subscription.autopay_enabled, days ) - await callback.answer( - texts.t("SUBSCRIPTION_AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days) - ) + await callback.answer(f"✅ Установлено {days} дней!") await handle_autopay_menu(callback, db_user, db) @@ -5444,22 +5310,15 @@ async def handle_switch_traffic( ): from app.config import settings - texts = get_texts(db_user.language) - if settings.is_traffic_fixed(): - await callback.answer( - texts.t("SUBSCRIPTION_TRAFFIC_FIXED_ALERT", "⚠️ В текущем режиме трафик фиксированный"), - show_alert=True, - ) + await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return + texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer( - texts.t("SUBSCRIPTION_FEATURE_PAID_ONLY", "⚠️ Эта функция доступна только для платных подписок"), - show_alert=True, - ) + await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return current_traffic = subscription.traffic_limit_gb @@ -5471,29 +5330,12 @@ async def handle_switch_traffic( ) await callback.message.edit_text( - "\n".join( - [ - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE", - "🔄 Переключение лимита трафика", - ), - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT", - "Текущий лимит: {traffic}", - ).format(traffic=texts.format_traffic(current_traffic)), - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT", - "Выберите новый лимит трафика:", - ), - "", - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT", - "💡 Важно:\n" - "• При увеличении - доплата за разницу\n" - "• При уменьшении - возврат средств не производится", - ), - ] - ), + f"🔄 Переключение лимита трафика\n\n" + f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" + f"Выберите новый лимит трафика:\n\n" + f"💡 Важно:\n" + f"• При увеличении - доплата за разницу\n" + f"• При уменьшении - возврат средств не производится", reply_markup=get_traffic_switch_keyboard( current_traffic, db_user.language, @@ -5518,13 +5360,7 @@ async def confirm_switch_traffic( current_traffic = subscription.traffic_limit_gb if new_traffic_gb == current_traffic: - await callback.answer( - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE", - "ℹ️ Лимит трафика не изменился", - ), - show_alert=True, - ) + await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True) return old_price_per_month = settings.get_traffic_price(current_traffic) @@ -5646,13 +5482,7 @@ async def execute_switch_traffic( ) if not success: - await callback.answer( - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR", - "⚠️ Ошибка списания средств", - ), - show_alert=True, - ) + await callback.answer("⚠️ Ошибка списания средств", show_alert=True) return months_remaining = get_remaining_months(subscription.end_date) @@ -5684,49 +5514,17 @@ async def execute_switch_traffic( except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}") - change_line = texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE", - "📊 Было: {old} → Стало: {new}", - ).format( - old=texts.format_traffic(current_traffic), - new=texts.format_traffic(new_traffic_gb), - ) - if new_traffic_gb > current_traffic: - success_parts = [ - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE", - "✅ Лимит трафика увеличен!", - ), - "", - change_line, - ] + success_text = f"✅ Лимит трафика увеличен!\n\n" + success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " + success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" if price_difference > 0: - success_parts.extend( - [ - "", - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED", - "💰 Списано: {amount}", - ).format(amount=texts.format_price(price_difference)), - ] - ) - else: - success_parts = [ - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE", - "✅ Лимит трафика уменьшен!", - ), - "", - change_line, - "", - texts.t( - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND", - "ℹ️ Возврат средств не производится", - ), - ] - - success_text = "\n".join(part for part in success_parts if part is not None) + success_text += f"💰 Списано: {texts.format_price(price_difference)}" + elif new_traffic_gb < current_traffic: + success_text = f"✅ Лимит трафика уменьшен!\n\n" + success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " + success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" + success_text += f"ℹ️ Возврат средств не производится" await callback.message.edit_text( success_text, @@ -5754,16 +5552,12 @@ def get_traffic_switch_keyboard( ) -> InlineKeyboardMarkup: from app.config import settings - texts = get_texts(language) months_multiplier = 1 period_text = "" if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = texts.t( - "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX", - " за {months} мес", - ).format(months=months_multiplier) + period_text = f" (за {months_multiplier} мес)" packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] @@ -5789,57 +5583,34 @@ def get_traffic_switch_keyboard( if gb == current_traffic_gb: emoji = "✅" - suffix = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT", - " (текущий)", - ) + action_text = " (текущий)" + price_text = "" elif total_price_diff > 0: emoji = "⬆️" - suffix = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP", - " (+{amount}{period})", - ).format( - amount=texts.format_price(total_price_diff), - period=period_text, - ) + action_text = "" + price_text = f" (+{total_price_diff // 100}₽{period_text})" if discount_percent > 0: discount_total = ( - (price_per_month - current_price_per_month) * months_multiplier - - total_price_diff + (price_per_month - current_price_per_month) * months_multiplier + - total_price_diff ) if discount_total > 0: - suffix += texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT", - " (скидка {percent}%: -{amount})", - ).format( - percent=discount_percent, - amount=texts.format_price(discount_total), - ) + price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)" elif total_price_diff < 0: emoji = "⬇️" - suffix = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND", - " (без возврата)", - ) + action_text = "" + price_text = " (без возврата)" else: emoji = "🔄" - suffix = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_FREE", - " (бесплатно)", - ) + action_text = "" + price_text = " (бесплатно)" if gb == 0: - traffic_text = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED", - "Безлимит", - ) + traffic_text = "Безлимит" else: - traffic_text = texts.t( - "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED", - "{gb} ГБ", - ).format(gb=gb) + traffic_text = f"{gb} ГБ" - button_text = f"{emoji} {traffic_text}{suffix}" + button_text = f"{emoji} {traffic_text}{action_text}{price_text}" buttons.append([ InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}") @@ -5847,7 +5618,7 @@ def get_traffic_switch_keyboard( buttons.append([ InlineKeyboardButton( - text=texts.BACK, + text="⬅️ Назад" if language == "ru" else "⬅️ Back", callback_data="subscription_settings" ) ]) @@ -5860,17 +5631,16 @@ def get_confirm_switch_traffic_keyboard( price_difference: int, language: str = "ru" ) -> InlineKeyboardMarkup: - texts = get_texts(language) return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( - text=texts.CONFIRM_CHANGE_BUTTON, + text="✅ Подтвердить переключение", callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}" ) ], [ InlineKeyboardButton( - text=texts.CANCEL, + text="❌ Отмена", callback_data="subscription_settings" ) ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 07019749..2cd0fec1 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -638,57 +638,5 @@ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.", "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.", "ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below", - "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted", - "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Settings are available only for paid subscriptions", - "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries}\n📈 Traffic: {traffic}\n📱 Devices: {devices}\n\nChoose what you want to change:", - "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ You don't have an active subscription!", - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "enabled", - "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "disabled", - "SUBSCRIPTION_AUTOPAY_MENU": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiration\n\nChoose an action:", - "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Auto payment {status}!", - "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Choose how many days before expiration to charge:", - "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Set to {days} days!", - "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠️ This feature is only available for paid subscriptions", - "SUBSCRIPTION_COUNTRIES_NONE": "No connected countries", - "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Manage subscription countries", - "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Current countries ({count}):\n{countries}", - "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 How it works:\n✅ - country connected\n➕ - will be added (paid)\n➖ - will be removed (free)\n⚪️ - not selected", - "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Important: Reconnecting removed countries will incur an additional charge!", - "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ This server is not available for your promo group", - "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Change device limit", - "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Current limit: {count} devices", - "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Choose a new device limit:", - "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Important:\n• Increasing — additional charge proportional to the remaining time\n• Decreasing — no refunds", - "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ The number of devices hasn't changed", - "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Maximum device limit exceeded ({limit})", - "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ User UUID not found", - "SUBSCRIPTION_DEVICES_NONE": "ℹ️ You don't have any connected devices", - "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Failed to retrieve device information", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Manage devices", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Total connected: {count} devices", - "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Page {current} of {total}", - "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Connected devices:", - "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Unknown", - "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Unknown", - "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Actions:\n• Select a device to reset\n• Or reset all devices at once", - "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ Traffic is fixed in the current mode", - "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", - "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Current limit: {traffic}", - "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Choose a new traffic limit:", - "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Important:\n• Increasing — pay the difference\n• Decreasing — no refunds", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ The traffic limit hasn't changed", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Failed to charge funds", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Previously: {old} → Now: {new}", - "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Traffic limit increased!", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Charged: {amount}", - "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Traffic limit decreased!", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Refunds are not provided", - "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " for {months} mo.", - "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (current)", - "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", - "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (discount {percent}%: -{amount})", - "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (no refund)", - "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (free)", - "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Unlimited", - "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} GB" + "ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 3c62264c..3dabdd53 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -638,57 +638,5 @@ "ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.", "ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.", "ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже", - "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено", - "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Настройки доступны только для платных подписок", - "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries}\n📈 Трафик: {traffic}\n📱 Устройства: {devices}\n\nВыберите что хотите изменить:", - "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ У вас нет активной подписки!", - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "включен", - "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "выключен", - "SUBSCRIPTION_AUTOPAY_MENU": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:", - "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Автоплатеж {status}!", - "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Выберите за сколько дней до окончания списывать средства:", - "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!", - "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠ Эта функция доступна только для платных подписок", - "SUBSCRIPTION_COUNTRIES_NONE": "Нет подключенных стран", - "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Управление странами подписки", - "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Текущие страны ({count}):\n{countries}", - "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪️ - не выбрана", - "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Важно: Повторное подключение отключенных стран будет платным!", - "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ Сервер недоступен для вашей промогруппы", - "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", - "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Текущий лимит: {count} устройств", - "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Выберите новое количество устройств:", - "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится", - "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", - "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Превышен максимальный лимит устройств ({limit})", - "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ UUID пользователя не найден", - "SUBSCRIPTION_DEVICES_NONE": "ℹ️ У вас нет подключенных устройств", - "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Ошибка получения информации об устройствах", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Управление устройствами", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Всего подключено: {count} устройств", - "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Страница {current} из {total}", - "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Подключенные устройства:", - "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Неизвестно", - "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Неизвестно", - "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу", - "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ В текущем режиме трафик фиксированный", - "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", - "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Текущий лимит: {traffic}", - "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Выберите новый лимит трафика:", - "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Важно:\n• При увеличении - доплата за разницу\n• При уменьшении - возврат средств не производится", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Ошибка списания средств", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Было: {old} → Стало: {new}", - "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Лимит трафика увеличен!", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Списано: {amount}", - "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Лимит трафика уменьшен!", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Возврат средств не производится", - "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " за {months} мес", - "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (текущий)", - "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", - "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (скидка {percent}%: -{amount})", - "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (без возврата)", - "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (бесплатно)", - "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Безлимит", - "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} ГБ" + "ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено" } diff --git a/locales/en.json b/locales/en.json index c660b19d..d970b566 100644 --- a/locales/en.json +++ b/locales/en.json @@ -815,57 +815,5 @@ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect", "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription", "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support", - "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu", - "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Settings are available only for paid subscriptions", - "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries}\n📈 Traffic: {traffic}\n📱 Devices: {devices}\n\nChoose what you want to change:", - "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ You don't have an active subscription!", - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "enabled", - "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "disabled", - "SUBSCRIPTION_AUTOPAY_MENU": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiration\n\nChoose an action:", - "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Auto payment {status}!", - "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Choose how many days before expiration to charge:", - "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Set to {days} days!", - "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠️ This feature is only available for paid subscriptions", - "SUBSCRIPTION_COUNTRIES_NONE": "No connected countries", - "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Manage subscription countries", - "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Current countries ({count}):\n{countries}", - "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 How it works:\n✅ - country connected\n➕ - will be added (paid)\n➖ - will be removed (free)\n⚪️ - not selected", - "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Important: Reconnecting removed countries will incur an additional charge!", - "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ This server is not available for your promo group", - "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Change device limit", - "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Current limit: {count} devices", - "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Choose a new device limit:", - "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Important:\n• Increasing — additional charge proportional to the remaining time\n• Decreasing — no refunds", - "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ The number of devices hasn't changed", - "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Maximum device limit exceeded ({limit})", - "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ User UUID not found", - "SUBSCRIPTION_DEVICES_NONE": "ℹ️ You don't have any connected devices", - "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Failed to retrieve device information", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Manage devices", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Total connected: {count} devices", - "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Page {current} of {total}", - "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Connected devices:", - "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Unknown", - "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Unknown", - "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Actions:\n• Select a device to reset\n• Or reset all devices at once", - "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ Traffic is fixed in the current mode", - "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit", - "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Current limit: {traffic}", - "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Choose a new traffic limit:", - "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Important:\n• Increasing — pay the difference\n• Decreasing — no refunds", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ The traffic limit hasn't changed", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Failed to charge funds", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Previously: {old} → Now: {new}", - "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Traffic limit increased!", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Charged: {amount}", - "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Traffic limit decreased!", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Refunds are not provided", - "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " for {months} mo.", - "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (current)", - "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", - "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (discount {percent}%: -{amount})", - "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (no refund)", - "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (free)", - "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Unlimited", - "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} GB" + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu" } diff --git a/locales/ru.json b/locales/ru.json index ce189907..13c5c434 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -815,57 +815,5 @@ "ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться", "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка", "ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка", - "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную", - "SUBSCRIPTION_SETTINGS_PAID_ONLY_ALERT": "⚠️ Настройки доступны только для платных подписок", - "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries}\n📈 Трафик: {traffic}\n📱 Устройства: {devices}\n\nВыберите что хотите изменить:", - "SUBSCRIPTION_AUTOPAY_NO_SUBSCRIPTION": "⚠️ У вас нет активной подписки!", - "SUBSCRIPTION_AUTOPAY_STATUS_ENABLED": "включен", - "SUBSCRIPTION_AUTOPAY_STATUS_DISABLED": "выключен", - "SUBSCRIPTION_AUTOPAY_MENU": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:", - "SUBSCRIPTION_AUTOPAY_TOGGLED": "✅ Автоплатеж {status}!", - "SUBSCRIPTION_AUTOPAY_SELECT_DAYS": "⏰ Выберите за сколько дней до окончания списывать средства:", - "SUBSCRIPTION_AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!", - "SUBSCRIPTION_FEATURE_PAID_ONLY": "⚠ Эта функция доступна только для платных подписок", - "SUBSCRIPTION_COUNTRIES_NONE": "Нет подключенных стран", - "SUBSCRIPTION_COUNTRIES_MANAGEMENT_TITLE": "🌍 Управление странами подписки", - "SUBSCRIPTION_COUNTRIES_CURRENT": "📋 Текущие страны ({count}):\n{countries}", - "SUBSCRIPTION_COUNTRIES_INSTRUCTIONS": "💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪️ - не выбрана", - "SUBSCRIPTION_COUNTRIES_NOTICE": "⚠️ Важно: Повторное подключение отключенных стран будет платным!", - "SUBSCRIPTION_COUNTRY_NOT_AVAILABLE": "❌ Сервер недоступен для вашей промогруппы", - "SUBSCRIPTION_CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", - "SUBSCRIPTION_CHANGE_DEVICES_CURRENT": "Текущий лимит: {count} устройств", - "SUBSCRIPTION_CHANGE_DEVICES_PROMPT": "Выберите новое количество устройств:", - "SUBSCRIPTION_CHANGE_DEVICES_IMPORTANT": "💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится", - "SUBSCRIPTION_CHANGE_DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", - "SUBSCRIPTION_CHANGE_DEVICES_MAX_LIMIT": "⚠️ Превышен максимальный лимит устройств ({limit})", - "SUBSCRIPTION_DEVICES_UUID_NOT_FOUND": "❌ UUID пользователя не найден", - "SUBSCRIPTION_DEVICES_NONE": "ℹ️ У вас нет подключенных устройств", - "SUBSCRIPTION_DEVICES_FETCH_ERROR": "❌ Ошибка получения информации об устройствах", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TITLE": "🔄 Управление устройствами", - "SUBSCRIPTION_DEVICES_MANAGEMENT_TOTAL": "📊 Всего подключено: {count} устройств", - "SUBSCRIPTION_DEVICES_MANAGEMENT_PAGE": "📄 Страница {current} из {total}", - "SUBSCRIPTION_DEVICES_MANAGEMENT_LIST_HEADER": "Подключенные устройства:", - "SUBSCRIPTION_DEVICES_UNKNOWN_PLATFORM": "Неизвестно", - "SUBSCRIPTION_DEVICES_UNKNOWN_MODEL": "Неизвестно", - "SUBSCRIPTION_DEVICES_MANAGEMENT_ACTIONS": "💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу", - "SUBSCRIPTION_TRAFFIC_FIXED_ALERT": "⚠️ В текущем режиме трафик фиксированный", - "SUBSCRIPTION_SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика", - "SUBSCRIPTION_SWITCH_TRAFFIC_CURRENT": "Текущий лимит: {traffic}", - "SUBSCRIPTION_SWITCH_TRAFFIC_PROMPT": "Выберите новый лимит трафика:", - "SUBSCRIPTION_SWITCH_TRAFFIC_IMPORTANT": "💡 Важно:\n• При увеличении - доплата за разницу\n• При уменьшении - возврат средств не производится", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGE_ERROR": "⚠️ Ошибка списания средств", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHANGE_LINE": "📊 Было: {old} → Стало: {new}", - "SUBSCRIPTION_SWITCH_TRAFFIC_INCREASE_TITLE": "✅ Лимит трафика увеличен!", - "SUBSCRIPTION_SWITCH_TRAFFIC_CHARGED": "💰 Списано: {amount}", - "SUBSCRIPTION_SWITCH_TRAFFIC_DECREASE_TITLE": "✅ Лимит трафика уменьшен!", - "SUBSCRIPTION_SWITCH_TRAFFIC_NO_REFUND": "ℹ️ Возврат средств не производится", - "SUBSCRIPTION_TRAFFIC_PERIOD_SUFFIX": " за {months} мес", - "SUBSCRIPTION_TRAFFIC_OPTION_CURRENT": " (текущий)", - "SUBSCRIPTION_TRAFFIC_OPTION_PRICE_UP": " (+{amount}{period})", - "SUBSCRIPTION_TRAFFIC_OPTION_DISCOUNT": " (скидка {percent}%: -{amount})", - "SUBSCRIPTION_TRAFFIC_OPTION_NO_REFUND": " (без возврата)", - "SUBSCRIPTION_TRAFFIC_OPTION_FREE": " (бесплатно)", - "SUBSCRIPTION_TRAFFIC_OPTION_UNLIMITED": "Безлимит", - "SUBSCRIPTION_TRAFFIC_OPTION_LIMITED": "{gb} ГБ" + "ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную" } From 5f8295a58830ecd21b325685b33f7fbd816134b8 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 01:22:49 +0300 Subject: [PATCH 20/24] Add English localization for subscription settings flows --- app/handlers/subscription.py | 528 ++++++++++++++++++++++++------- app/localization/locales/en.json | 60 ++++ app/localization/locales/ru.json | 60 ++++ 3 files changed, 525 insertions(+), 123 deletions(-) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 923a07ca..52800062 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -1167,14 +1167,24 @@ async def handle_add_countries( state: FSMContext ): if not await _should_show_countries_management(db_user): - await callback.answer("ℹ️ Управление серверами недоступно - доступен только один сервер", show_alert=True) + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "COUNTRY_MANAGEMENT_UNAVAILABLE", + "ℹ️ Управление серверами недоступно - доступен только один сервер", + ), + show_alert=True, + ) return texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return countries = await _get_available_countries(db_user.promo_group_id) @@ -1192,19 +1202,29 @@ async def handle_add_countries( if country['uuid'] in current_countries: current_countries_names.append(country['name']) - text = "🌍 Управление странами подписки\n\n" - text += f"📋 Текущие страны ({len(current_countries)}):\n" - if current_countries_names: - text += "\n".join(f"• {name}" for name in current_countries_names) - else: - text += "Нет подключенных стран" + current_list = ( + "\n".join(f"• {name}" for name in current_countries_names) + if current_countries_names + else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран") + ) - text += "\n\n💡 Инструкция:\n" - text += "✅ - страна подключена\n" - text += "➕ - будет добавлена (платно)\n" - text += "➖ - будет отключена (бесплатно)\n" - text += "⚪ - не выбрана\n\n" - text += "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + text = texts.t( + "COUNTRY_MANAGEMENT_PROMPT", + ( + "🌍 Управление странами подписки\n\n" + "📋 Текущие страны ({current_count}):\n" + "{current_list}\n\n" + "💡 Инструкция:\n" + "✅ - страна подключена\n" + "➕ - будет добавлена (платно)\n" + "➖ - будет отключена (бесплатно)\n" + "⚪ - не выбрана\n\n" + "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + ), + ).format( + current_count=len(current_countries), + current_list=current_list, + ) await state.update_data(countries=current_countries.copy()) @@ -1276,7 +1296,11 @@ async def handle_manage_country( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Только для платных подписок", show_alert=True) + texts = get_texts(db_user.language) + await callback.answer( + texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"), + show_alert=True, + ) return data = await state.get_data() @@ -1286,7 +1310,14 @@ async def handle_manage_country( allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in current_selected: - await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "COUNTRY_NOT_AVAILABLE_PROMOGROUP", + "❌ Сервер недоступен для вашей промогруппы", + ), + show_alert=True, + ) return if country_uuid in current_selected: @@ -1361,7 +1392,10 @@ async def apply_countries_changes( removed = [c for c in current_countries if c not in selected_countries] if not added and not removed: - await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) + await callback.answer( + texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"), + show_alert=True, + ) return logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") @@ -1461,7 +1495,10 @@ async def apply_countries_changes( f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer( + texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"), + show_alert=True, + ) return await create_transaction( @@ -1503,26 +1540,50 @@ async def apply_countries_changes( except Exception as e: logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}") - success_text = "✅ Страны успешно обновлены!\n\n" + success_text = texts.t( + "COUNTRY_CHANGES_SUCCESS_HEADER", + "✅ Страны успешно обновлены!\n\n", + ) if added_names: - success_text += f"➕ Добавлены страны:\n" + success_text += texts.t( + "COUNTRY_CHANGES_ADDED_HEADER", + "➕ Добавлены страны:\n", + ) success_text += "\n".join(f"• {name}" for name in added_names) if total_cost > 0: - success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)" + success_text += "\n" + texts.t( + "COUNTRY_CHANGES_CHARGED", + "💰 Списано: {amount} (за {months} мес)", + ).format( + amount=texts.format_price(total_cost), + months=charged_months, + ) if total_discount > 0: - success_text += ( - f" (скидка {servers_discount_percent}%:" - f" -{texts.format_price(total_discount)})" + success_text += texts.t( + "COUNTRY_CHANGES_DISCOUNT_INFO", + " (скидка {percent}%: -{amount})", + ).format( + percent=servers_discount_percent, + amount=texts.format_price(total_discount), ) success_text += "\n" if removed_names: - success_text += f"\n➖ Отключены страны:\n" + success_text += "\n" + texts.t( + "COUNTRY_CHANGES_REMOVED_HEADER", + "➖ Отключены страны:\n", + ) success_text += "\n".join(f"• {name}" for name in removed_names) - success_text += "\nℹ️ Повторное подключение будет платным\n" + success_text += "\n" + texts.t( + "COUNTRY_CHANGES_REMOVED_WARNING", + "ℹ️ Повторное подключение будет платным", + ) + "\n" - success_text += f"\n🌐 Активных стран: {len(selected_countries)}" + success_text += "\n" + texts.t( + "COUNTRY_CHANGES_ACTIVE_COUNT", + "🌐 Активных стран: {count}", + ).format(count=len(selected_countries)) await callback.message.edit_text( success_text, @@ -1551,19 +1612,32 @@ async def handle_add_traffic( ): from app.config import settings + texts = get_texts(db_user.language) + if settings.is_traffic_fixed(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть изменен", show_alert=True) + await callback.answer( + texts.t( + "TRAFFIC_FIXED_MODE", + "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + ), + show_alert=True, + ) return - texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return if subscription.traffic_limit_gb == 0: - await callback.answer("⚠ У вас уже безлимитный трафик", show_alert=True) + await callback.answer( + texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"), + show_alert=True, + ) return current_traffic = subscription.traffic_limit_gb @@ -1574,10 +1648,17 @@ async def handle_add_traffic( period_hint_days, ) + prompt_text = texts.t( + "ADD_TRAFFIC_PROMPT", + ( + "📈 Добавить трафик к подписке\n\n" + "Текущий лимит: {current_traffic}\n" + "Выберите дополнительный трафик:" + ), + ).format(current_traffic=texts.format_traffic(current_traffic)) + await callback.message.edit_text( - f"📈 Добавить трафик к подписке\n\n" - f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" - f"Выберите дополнительный трафик:", + prompt_text, reply_markup=get_add_traffic_keyboard( db_user.language, subscription.end_date, @@ -1598,7 +1679,10 @@ async def handle_change_devices( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return current_devices = subscription.device_limit @@ -1610,13 +1694,20 @@ async def handle_change_devices( period_hint_days, ) + prompt_text = texts.t( + "CHANGE_DEVICES_PROMPT", + ( + "📱 Изменение количества устройств\n\n" + "Текущий лимит: {current_devices} устройств\n" + "Выберите новое количество устройств:\n\n" + "💡 Важно:\n" + "• При увеличении - доплата пропорционально оставшемуся времени\n" + "• При уменьшении - возврат средств не производится" + ), + ).format(current_devices=current_devices) + await callback.message.edit_text( - f"📱 Изменение количества устройств\n\n" - f"Текущий лимит: {current_devices} устройств\n" - f"Выберите новое количество устройств:\n\n" - f"💡 Важно:\n" - f"• При увеличении - доплата пропорционально оставшемуся времени\n" - f"• При уменьшении - возврат средств не производится", + prompt_text, reply_markup=get_change_devices_keyboard( current_devices, db_user.language, @@ -1641,12 +1732,18 @@ async def confirm_change_devices( current_devices = subscription.device_limit if new_devices_count == current_devices: - await callback.answer("ℹ️ Количество устройств не изменилось", show_alert=True) + await callback.answer( + texts.t("DEVICES_NO_CHANGE", "ℹ️ Количество устройств не изменилось"), + show_alert=True, + ) return if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( - f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})", + texts.t( + "DEVICES_LIMIT_EXCEEDED", + "⚠️ Превышен максимальный лимит устройств ({limit})", + ).format(limit=settings.MAX_DEVICES_LIMIT), show_alert=True ) return @@ -1709,28 +1806,53 @@ async def confirm_change_devices( await callback.answer() return - action_text = f"увеличить до {new_devices_count}" + action_text = texts.t( + "DEVICE_CHANGE_ACTION_INCREASE", + "увеличить до {count}", + ).format(count=new_devices_count) if price > 0: - cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" + cost_text = texts.t( + "DEVICE_CHANGE_EXTRA_COST", + "Доплата: {amount} (за {months} мес)", + ).format( + amount=texts.format_price(price), + months=charged_months, + ) if total_discount > 0: - cost_text += ( - f" (скидка {devices_discount_percent}%:" - f" -{texts.format_price(total_discount)})" + cost_text += texts.t( + "DEVICE_CHANGE_DISCOUNT_INFO", + " (скидка {percent}%: -{amount})", + ).format( + percent=devices_discount_percent, + amount=texts.format_price(total_discount), ) else: - cost_text = "Бесплатно" + cost_text = texts.t("DEVICE_CHANGE_FREE", "Бесплатно") else: price = 0 - action_text = f"уменьшить до {new_devices_count}" - cost_text = "Возврат средств не производится" + action_text = texts.t( + "DEVICE_CHANGE_ACTION_DECREASE", + "уменьшить до {count}", + ).format(count=new_devices_count) + cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится") - confirm_text = f"📱 Подтверждение изменения\n\n" - confirm_text += f"Текущее количество: {current_devices} устройств\n" - confirm_text += f"Новое количество: {new_devices_count} устройств\n\n" - confirm_text += f"Действие: {action_text}\n" - confirm_text += f"💰 {cost_text}\n\n" - confirm_text += "Подтвердить изменение?" + confirm_text = texts.t( + "DEVICE_CHANGE_CONFIRMATION", + ( + "📱 Подтверждение изменения\n\n" + "Текущее количество: {current} устройств\n" + "Новое количество: {new} устройств\n\n" + "Действие: {action}\n" + "💰 {cost}\n\n" + "Подтвердить изменение?" + ), + ).format( + current=current_devices, + new=new_devices_count, + action=action_text, + cost=cost_text, + ) await callback.message.edit_text( confirm_text, @@ -1762,7 +1884,10 @@ async def execute_change_devices( ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer( + texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"), + show_alert=True, + ) return charged_months = get_remaining_months(subscription.end_date) @@ -1795,14 +1920,32 @@ async def execute_change_devices( logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}") if new_devices_count > current_devices: - success_text = f"✅ Количество устройств увеличено!\n\n" - success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" + success_text = texts.t( + "DEVICE_CHANGE_INCREASE_SUCCESS", + "✅ Количество устройств увеличено!\n\n", + ) + success_text += texts.t( + "DEVICE_CHANGE_RESULT_LINE", + "📱 Было: {old} → Стало: {new}\n", + ).format(old=current_devices, new=new_devices_count) if price > 0: - success_text += f"💰 Списано: {texts.format_price(price)}" + success_text += texts.t( + "DEVICE_CHANGE_CHARGED", + "💰 Списано: {amount}", + ).format(amount=texts.format_price(price)) else: - success_text = f"✅ Количество устройств уменьшено!\n\n" - success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n" - success_text += f"ℹ️ Возврат средств не производится" + success_text = texts.t( + "DEVICE_CHANGE_DECREASE_SUCCESS", + "✅ Количество устройств уменьшено!\n\n", + ) + success_text += texts.t( + "DEVICE_CHANGE_RESULT_LINE", + "📱 Было: {old} → Стало: {new}\n", + ).format(old=current_devices, new=new_devices_count) + success_text += texts.t( + "DEVICE_CHANGE_NO_REFUND_INFO", + "ℹ️ Возврат средств не производится", + ) await callback.message.edit_text( success_text, @@ -1831,11 +1974,17 @@ async def handle_device_management( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer( + texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + show_alert=True, + ) return if not db_user.remnawave_uuid: - await callback.answer("❌ UUID пользователя не найден", show_alert=True) + await callback.answer( + texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), + show_alert=True, + ) return try: @@ -1852,7 +2001,7 @@ async def handle_device_management( if total_devices == 0: await callback.message.edit_text( - "ℹ️ У вас нет подключенных устройств", + texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"), reply_markup=get_back_keyboard(db_user.language) ) await callback.answer() @@ -1860,11 +2009,23 @@ async def handle_device_management( await show_devices_page(callback, db_user, devices_list, page=1) else: - await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + await callback.answer( + texts.t( + "DEVICE_FETCH_INFO_ERROR", + "❌ Ошибка получения информации об устройствах", + ), + show_alert=True, + ) except Exception as e: logger.error(f"Ошибка получения списка устройств: {e}") - await callback.answer("❌ Ошибка получения информации об устройствах", show_alert=True) + await callback.answer( + texts.t( + "DEVICE_FETCH_INFO_ERROR", + "❌ Ошибка получения информации об устройствах", + ), + show_alert=True, + ) await callback.answer() @@ -1880,12 +2041,20 @@ async def show_devices_page( pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) - devices_text = f"🔄 Управление устройствами\n\n" - devices_text += f"📊 Всего подключено: {len(devices_list)} устройств\n" - devices_text += f"📄 Страница {pagination.page} из {pagination.total_pages}\n\n" + devices_text = texts.t( + "DEVICE_MANAGEMENT_OVERVIEW", + ( + "🔄 Управление устройствами\n\n" + "📊 Всего подключено: {total} устройств\n" + "📄 Страница {page} из {pages}\n\n" + ), + ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages) if pagination.items: - devices_text += "Подключенные устройства:\n" + devices_text += texts.t( + "DEVICE_MANAGEMENT_CONNECTED_HEADER", + "Подключенные устройства:\n", + ) for i, device in enumerate(pagination.items, 1): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') @@ -1894,11 +2063,19 @@ async def show_devices_page( if len(device_info) > 35: device_info = device_info[:32] + "..." - devices_text += f"• {device_info}\n" + devices_text += texts.t( + "DEVICE_MANAGEMENT_LIST_ITEM", + "• {device}\n", + ).format(device=device_info) - devices_text += "\n💡 Действия:\n" - devices_text += "• Выберите устройство для сброса\n" - devices_text += "• Или сбросьте все устройства сразу" + devices_text += texts.t( + "DEVICE_MANAGEMENT_ACTIONS", + ( + "\n💡 Действия:\n" + "• Выберите устройство для сброса\n" + "• Или сбросьте все устройства сразу" + ), + ) await callback.message.edit_text( devices_text, @@ -1917,6 +2094,7 @@ async def handle_devices_page( db: AsyncSession ): page = int(callback.data.split('_')[2]) + texts = get_texts(db_user.language) try: from app.services.remnawave_service import RemnaWaveService @@ -1929,11 +2107,17 @@ async def handle_devices_page( devices_list = response['response'].get('devices', []) await show_devices_page(callback, db_user, devices_list, page=page) else: - await callback.answer("❌ Ошибка получения устройств", show_alert=True) + await callback.answer( + texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"), + show_alert=True, + ) except Exception as e: logger.error(f"Ошибка перехода на страницу устройств: {e}") - await callback.answer("❌ Ошибка загрузки страницы", show_alert=True) + await callback.answer( + texts.t("DEVICE_PAGE_LOAD_ERROR", "❌ Ошибка загрузки страницы"), + show_alert=True, + ) async def handle_single_device_reset( @@ -1945,7 +2129,10 @@ async def handle_single_device_reset( callback_parts = callback.data.split('_') if len(callback_parts) < 4: logger.error(f"Некорректный формат callback_data: {callback.data}") - await callback.answer("❌ Ошибка: некорректный запрос", show_alert=True) + await callback.answer( + texts.t("DEVICE_RESET_INVALID_REQUEST", "❌ Ошибка: некорректный запрос"), + show_alert=True, + ) return device_index = int(callback_parts[2]) @@ -1955,7 +2142,10 @@ async def handle_single_device_reset( except (ValueError, IndexError) as e: logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}") - await callback.answer("❌ Ошибка обработки запроса", show_alert=True) + await callback.answer( + texts.t("DEVICE_RESET_PARSE_ERROR", "❌ Ошибка обработки запроса"), + show_alert=True, + ) return texts = get_texts(db_user.language) @@ -1989,7 +2179,13 @@ async def handle_single_device_reset( device_model = device.get('deviceModel', 'Unknown') device_info = f"{platform} - {device_model}" - await callback.answer(f"✅ Устройство {device_info} успешно сброшено!", show_alert=True) + await callback.answer( + texts.t( + "DEVICE_RESET_SUCCESS", + "✅ Устройство {device} успешно сброшено!", + ).format(device=device_info), + show_alert=True, + ) updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if updated_response and 'response' in updated_response: @@ -2004,21 +2200,39 @@ async def handle_single_device_reset( await show_devices_page(callback, db_user, updated_devices, page=page) else: await callback.message.edit_text( - "ℹ️ Все устройства сброшены", + texts.t( + "DEVICE_RESET_ALL_DONE", + "ℹ️ Все устройства сброшены", + ), reply_markup=get_back_keyboard(db_user.language) ) logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}") else: - await callback.answer("❌ Не удалось получить ID устройства", show_alert=True) + await callback.answer( + texts.t( + "DEVICE_RESET_ID_FAILED", + "❌ Не удалось получить ID устройства", + ), + show_alert=True, + ) else: - await callback.answer("❌ Устройство не найдено", show_alert=True) + await callback.answer( + texts.t("DEVICE_RESET_NOT_FOUND", "❌ Устройство не найдено"), + show_alert=True, + ) else: - await callback.answer("❌ Ошибка получения устройств", show_alert=True) + await callback.answer( + texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"), + show_alert=True, + ) except Exception as e: logger.error(f"Ошибка сброса устройства: {e}") - await callback.answer("❌ Ошибка сброса устройства", show_alert=True) + await callback.answer( + texts.t("DEVICE_RESET_ERROR", "❌ Ошибка сброса устройства"), + show_alert=True, + ) async def handle_all_devices_reset_from_management( @@ -2029,7 +2243,10 @@ async def handle_all_devices_reset_from_management( texts = get_texts(db_user.language) if not db_user.remnawave_uuid: - await callback.answer("❌ UUID пользователя не найден", show_alert=True) + await callback.answer( + texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), + show_alert=True, + ) return try: @@ -2040,13 +2257,22 @@ async def handle_all_devices_reset_from_management( devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}') if not devices_response or 'response' not in devices_response: - await callback.answer("❌ Ошибка получения списка устройств", show_alert=True) + await callback.answer( + texts.t( + "DEVICE_LIST_FETCH_ERROR", + "❌ Ошибка получения списка устройств", + ), + show_alert=True, + ) return devices_list = devices_response['response'].get('devices', []) if not devices_list: - await callback.answer("ℹ️ У вас нет подключенных устройств", show_alert=True) + await callback.answer( + texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"), + show_alert=True, + ) return logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса") @@ -2077,20 +2303,30 @@ async def handle_all_devices_reset_from_management( if success_count > 0: if failed_count == 0: await callback.message.edit_text( - f"✅ Все устройства успешно сброшены!\n\n" - f"🔄 Сброшено: {success_count} устройств\n" - f"📱 Теперь вы можете заново подключить свои устройства\n\n" - f"💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", + texts.t( + "DEVICE_RESET_ALL_SUCCESS_MESSAGE", + ( + "✅ Все устройства успешно сброшены!\n\n" + "🔄 Сброшено: {count} устройств\n" + "📱 Теперь вы можете заново подключить свои устройства\n\n" + "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения" + ), + ).format(count=success_count), reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств") else: await callback.message.edit_text( - f"⚠️ Частичный сброс устройств\n\n" - f"✅ Удалено: {success_count} устройств\n" - f"❌ Не удалось удалить: {failed_count} устройств\n\n" - f"Попробуйте еще раз или обратитесь в поддержку.", + texts.t( + "DEVICE_RESET_PARTIAL_MESSAGE", + ( + "⚠️ Частичный сброс устройств\n\n" + "✅ Удалено: {success} устройств\n" + "❌ Не удалось удалить: {failed} устройств\n\n" + "Попробуйте еще раз или обратитесь в поддержку." + ), + ).format(success=success_count, failed=failed_count), reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) @@ -2098,9 +2334,14 @@ async def handle_all_devices_reset_from_management( f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") else: await callback.message.edit_text( - f"❌ Не удалось сбросить устройства\n\n" - f"Попробуйте еще раз позже или обратитесь в техподдержку.\n\n" - f"Всего устройств: {len(devices_list)}", + texts.t( + "DEVICE_RESET_ALL_FAILED_MESSAGE", + ( + "❌ Не удалось сбросить устройства\n\n" + "Попробуйте еще раз позже или обратитесь в техподдержку.\n\n" + "Всего устройств: {total}" + ), + ).format(total=len(devices_list)), reply_markup=get_back_keyboard(db_user.language), parse_mode="HTML" ) @@ -3926,21 +4167,34 @@ async def handle_subscription_settings( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Настройки доступны только для платных подписок", show_alert=True) + await callback.answer( + texts.t( + "SUBSCRIPTION_SETTINGS_PAID_ONLY", + "⚠️ Настройки доступны только для платных подписок", + ), + show_alert=True, + ) return devices_used = await get_current_devices_count(db_user) - settings_text = f""" -⚙️ Настройки подписки - -📊 Текущие параметры: -🌐 Стран: {len(subscription.connected_squads)} -📈 Трафик: {texts.format_traffic(subscription.traffic_used_gb)} / {texts.format_traffic(subscription.traffic_limit_gb)} -📱 Устройства: {devices_used} / {subscription.device_limit} - -Выберите что хотите изменить: -""" + settings_text = texts.t( + "SUBSCRIPTION_SETTINGS_OVERVIEW", + ( + "⚙️ Настройки подписки\n\n" + "📊 Текущие параметры:\n" + "🌐 Стран: {countries_count}\n" + "📈 Трафик: {traffic_used} / {traffic_limit}\n" + "📱 Устройства: {devices_used} / {devices_limit}\n\n" + "Выберите что хотите изменить:" + ), + ).format( + countries_count=len(subscription.connected_squads), + traffic_used=texts.format_traffic(subscription.traffic_used_gb), + traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), + devices_used=devices_used, + devices_limit=subscription.device_limit, + ) show_countries = await _should_show_countries_management(db_user) @@ -3957,22 +4211,36 @@ async def handle_autopay_menu( db_user: User, db: AsyncSession ): + texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription: - await callback.answer("⚠️ У вас нет активной подписки!", show_alert=True) + await callback.answer( + texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"), + show_alert=True, + ) return - status = "включен" if subscription.autopay_enabled else "выключен" + status = ( + texts.t("AUTOPAY_STATUS_ENABLED", "включен") + if subscription.autopay_enabled + else texts.t("AUTOPAY_STATUS_DISABLED", "выключен") + ) days = subscription.autopay_days_before - text = f"💳 Автоплатеж\n\n" - text += f"📊 Статус: {status}\n" - text += f"⏰ Списание за: {days} дн. до окончания\n\n" - text += "Выберите действие:" + text = texts.t( + "AUTOPAY_MENU_TEXT", + ( + "💳 Автоплатеж\n\n" + "📊 Статус: {status}\n" + "⏰ Списание за: {days} дн. до окончания\n\n" + "Выберите действие:" + ), + ).format(status=status, days=days) await callback.message.edit_text( text, - reply_markup=get_autopay_keyboard(db_user.language) + reply_markup=get_autopay_keyboard(db_user.language), + parse_mode="HTML", ) await callback.answer() @@ -3987,8 +4255,15 @@ async def toggle_autopay( await update_subscription_autopay(db, subscription, enable) - status = "включен" if enable else "выключен" - await callback.answer(f"✅ Автоплатеж {status}!") + texts = get_texts(db_user.language) + status = ( + texts.t("AUTOPAY_STATUS_ENABLED", "включен") + if enable + else texts.t("AUTOPAY_STATUS_DISABLED", "выключен") + ) + await callback.answer( + texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status) + ) await handle_autopay_menu(callback, db_user, db) @@ -3997,8 +4272,12 @@ async def show_autopay_days( callback: types.CallbackQuery, db_user: User ): + texts = get_texts(db_user.language) await callback.message.edit_text( - "⏰ Выберите за сколько дней до окончания списывать средства:", + texts.t( + "AUTOPAY_SELECT_DAYS_PROMPT", + "⏰ Выберите за сколько дней до окончания списывать средства:", + ), reply_markup=get_autopay_days_keyboard(db_user.language) ) await callback.answer() @@ -4016,7 +4295,10 @@ async def set_autopay_days( db, subscription, subscription.autopay_enabled, days ) - await callback.answer(f"✅ Установлено {days} дней!") + texts = get_texts(db_user.language) + await callback.answer( + texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days) + ) await handle_autopay_menu(callback, db_user, db) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 2cd0fec1..84750376 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1,14 +1,35 @@ { "ADD_COUNTRIES_BUTTON": "🌐 Add countries", + "COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible", + "COUNTRY_MANAGEMENT_PROMPT": "🌍 Manage subscription countries\n\n📋 Current countries ({current_count}):\n{current_list}\n\n💡 How it works:\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ Important: Reconnecting removed countries will be charged again!", + "COUNTRY_MANAGEMENT_NONE": "No countries connected", + "PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions", + "PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only", + "COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group", + "COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected", + "COUNTRY_CHANGES_SUCCESS_HEADER": "✅ Countries updated!\n\n", + "COUNTRY_CHANGES_ADDED_HEADER": "➕ Added countries:\n", + "COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)", + "COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})", + "COUNTRY_CHANGES_REMOVED_HEADER": "➖ Removed countries:\n", + "COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged", + "COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 Active countries: {count}", "ADMIN_MAIN_MENU": "🏠 Main menu", "ADMIN_CAMPAIGNS": "📣 Promotional campaigns", "AUTOPAY_BUTTON": "💳 Auto payment", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", + "AUTOPAY_STATUS_ENABLED": "enabled", + "AUTOPAY_STATUS_DISABLED": "disabled", + "AUTOPAY_MENU_TEXT": "💳 Auto payment\n\n📊 Status: {status}\n⏰ Charge: {days} days before expiry\n\nChoose an action:", + "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!", + "AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:", + "AUTOPAY_DAYS_SET": "✅ Set to {days} days!", "BACK": "⬅️ Back", "BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription", "BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}", "CANCEL": "❌ Cancel", "CHANGE_DEVICES_BUTTON": "📱 Change devices", + "CHANGE_DEVICES_PROMPT": "📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded", "CHANNEL_CHECK_BUTTON": "✅ I have joined", "CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe", @@ -56,6 +77,26 @@ "MAIN_MENU_ACTION_PROMPT": "Choose an option:", "MAIN_MENU_BUTTON": "🏠 Main menu", "MANAGE_DEVICES_BUTTON": "🔧 Manage devices", + "DEVICE_UUID_NOT_FOUND": "❌ User UUID not found", + "DEVICE_NONE_CONNECTED": "ℹ️ You have no connected devices", + "DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information", + "DEVICE_MANAGEMENT_OVERVIEW": "🔄 Device management\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n", + "DEVICE_MANAGEMENT_CONNECTED_HEADER": "Connected devices:\n", + "DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n", + "DEVICE_MANAGEMENT_ACTIONS": "\n💡 Actions:\n• Select a device to reset\n• Or reset all devices at once", + "DEVICE_FETCH_ERROR": "❌ Failed to load devices", + "DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page", + "DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request", + "DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request", + "DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!", + "DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset", + "DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID", + "DEVICE_RESET_NOT_FOUND": "❌ Device not found", + "DEVICE_RESET_ERROR": "❌ Failed to reset the device", + "DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list", + "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ All devices have been reset!\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect", + "DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ Devices reset partially\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.", + "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Couldn't reset devices\n\nPlease try again later or contact support.\n\nTotal devices: {total}", "MENU_BALANCE": "💰 Balance", "MENU_SUBSCRIPTION": "📱 Subscription", "MENU_TRIAL": "🎁 Trial subscription", @@ -98,6 +139,9 @@ "SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link", "SKIP_BUTTON": "Skip ➡️", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:", + "SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!", "SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days", "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", @@ -236,6 +280,19 @@ "DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})", "DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}", "DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed", + "PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment", + "DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}", + "DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)", + "DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})", + "DEVICE_CHANGE_FREE": "Free", + "DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}", + "DEVICE_CHANGE_NO_REFUND": "Payments are not refunded", + "DEVICE_CHANGE_CONFIRMATION": "📱 Confirm change\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?", + "DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n", + "DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n", + "DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}", + "DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n", + "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded", "INVALID_AMOUNT": "❌ Invalid amount", "MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n", "MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n", @@ -316,6 +373,9 @@ "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB\n📱 Devices: {devices} pcs\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", + "TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed", + "TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic", + "ADD_TRAFFIC_PROMPT": "📈 Add traffic to your subscription\n\nCurrent limit: {current_traffic}\nChoose extra traffic:", "USER_NOT_FOUND": "❌ User not found", "MENU_LANGUAGE": "🌐 Language", "SUBSCRIPTION_STATUS_EXPIRED": "Expired", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 3dabdd53..4ca51d14 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1,6 +1,20 @@ { "ACCESS_DENIED": "❌ Доступ запрещен", "ADD_COUNTRIES_BUTTON": "🌐 Добавить страны", + "COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер", + "COUNTRY_MANAGEMENT_PROMPT": "🌍 Управление странами подписки\n\n📋 Текущие страны ({current_count}):\n{current_list}\n\n💡 Инструкция:\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ Важно: Повторное подключение отключенных стран будет платным!", + "COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран", + "PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок", + "PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок", + "COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы", + "COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены", + "COUNTRY_CHANGES_SUCCESS_HEADER": "✅ Страны успешно обновлены!\n\n", + "COUNTRY_CHANGES_ADDED_HEADER": "➕ Добавлены страны:\n", + "COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)", + "COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})", + "COUNTRY_CHANGES_REMOVED_HEADER": "➖ Отключены страны:\n", + "COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным", + "COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 Активных стран: {count}", "ADMIN_MAIN_MENU": "🏠 Главное меню", "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", @@ -88,6 +102,12 @@ "AUTOPAY_FAILED": "\n❌ Ошибка автоплатежа\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n", "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", + "AUTOPAY_STATUS_ENABLED": "включен", + "AUTOPAY_STATUS_DISABLED": "выключен", + "AUTOPAY_MENU_TEXT": "💳 Автоплатеж\n\n📊 Статус: {status}\n⏰ Списание за: {days} дн. до окончания\n\nВыберите действие:", + "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", + "AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:", + "AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!", "BACK": "⬅️ Назад", "BACK_TO_SUBSCRIPTION": "⬅️ К подписке", "BALANCE_BUTTON": "💰 Баланс: {balance}", @@ -109,6 +129,7 @@ "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", + "CHANGE_DEVICES_PROMPT": "📱 Изменение количества устройств\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 Важно:\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ", "CHANGE_DEVICES_INFO": "\n 📱 Изменение количества устройств\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 Важно:\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ", "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", @@ -145,6 +166,19 @@ "DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})", "DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}", "DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось", + "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}", + "DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)", + "DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})", + "DEVICE_CHANGE_FREE": "Бесплатно", + "DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}", + "DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится", + "DEVICE_CHANGE_CONFIRMATION": "📱 Подтверждение изменения\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?", + "DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n", + "DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n", + "DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}", + "DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n", + "DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится", "DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?", "DEVICE_GUIDE_ANDROID": "🤖 Android", "DEVICE_GUIDE_ANDROID_TV": "📺 Android TV", @@ -170,6 +204,26 @@ "MAIN_MENU_ACTION_PROMPT": "Выберите действие:", "MAIN_MENU_BUTTON": "🏠 Главное меню", "MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами", + "DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден", + "DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств", + "DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах", + "DEVICE_MANAGEMENT_OVERVIEW": "🔄 Управление устройствами\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n", + "DEVICE_MANAGEMENT_CONNECTED_HEADER": "Подключенные устройства:\n", + "DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n", + "DEVICE_MANAGEMENT_ACTIONS": "\n💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу", + "DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств", + "DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы", + "DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос", + "DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса", + "DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!", + "DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены", + "DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства", + "DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено", + "DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства", + "DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств", + "DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ Все устройства успешно сброшены!\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения", + "DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ Частичный сброс устройств\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.", + "DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ Не удалось сбросить устройства\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}", "MENU_ADMIN": "⚙️ Админ-панель", "MENU_BALANCE": "💰 Баланс", "MENU_BUY_SUBSCRIPTION": "💎 Купить подписку", @@ -251,6 +305,9 @@ "SUBSCRIPTION_NONE": "❌ Нет активной подписки", "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", + "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок", + "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:", + "SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", @@ -314,6 +371,9 @@ "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ\n📱 Устройства: {devices} шт.\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", + "TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + "TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик", + "ADD_TRAFFIC_PROMPT": "📈 Добавить трафик к подписке\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:", "UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.", "UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.", "USER_NOT_FOUND": "❌ Пользователь не найден", From 818ef1ccb0cbba360a09cc43dcfe2262c86a3252 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:03:30 +0300 Subject: [PATCH 21/24] Create index.html --- miniapp/index.html | 724 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 724 insertions(+) create mode 100644 miniapp/index.html diff --git a/miniapp/index.html b/miniapp/index.html new file mode 100644 index 00000000..5ea8ab7f --- /dev/null +++ b/miniapp/index.html @@ -0,0 +1,724 @@ + + + + + + + VPN Subscription + + + + +
+ +
+ +
Secure & Fast Connection
+
+ + +
+
+
Loading your subscription...
+
+ + + + + + +
+ + + + From 70ad084bcd14a5893aff9ddc13c3163d4ca100d4 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:04:20 +0300 Subject: [PATCH 22/24] Add tariff-aware subscription API handling --- app/config.py | 18 +- app/database/crud/subscription.py | 42 ++- app/database/crud/tariff.py | 266 ++++++++++++++++ app/database/models.py | 127 +++++++- app/handlers/subscription.py | 467 +++++++++++++++++++++++++++- app/keyboards/inline.py | 119 ++++++- app/services/tariff_service.py | 45 +++ app/states.py | 2 + app/webapi/app.py | 6 + app/webapi/routes/__init__.py | 2 + app/webapi/routes/subscriptions.py | 12 +- app/webapi/routes/tariffs.py | 122 ++++++++ app/webapi/schemas/subscriptions.py | 2 + app/webapi/schemas/tariffs.py | 48 +++ 14 files changed, 1244 insertions(+), 34 deletions(-) create mode 100644 app/database/crud/tariff.py create mode 100644 app/services/tariff_service.py create mode 100644 app/webapi/routes/tariffs.py create mode 100644 app/webapi/schemas/tariffs.py diff --git a/app/config.py b/app/config.py index a2cbaf80..7a8c275e 100644 --- a/app/config.py +++ b/app/config.py @@ -117,7 +117,8 @@ class Settings(BaseSettings): BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = "" - TRAFFIC_SELECTION_MODE: str = "selectable" + TRAFFIC_SELECTION_MODE: str = "selectable" + SUBSCRIPTION_PURCHASE_MODE: str = "custom" FIXED_TRAFFIC_LIMIT_GB: int = 100 REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000 @@ -527,7 +528,20 @@ class Settings(BaseSettings): def is_traffic_fixed(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" - + + def get_subscription_purchase_mode(self) -> str: + mode = (self.SUBSCRIPTION_PURCHASE_MODE or "custom").strip().lower() + return mode or "custom" + + def is_subscription_tariff_mode(self) -> bool: + return self.get_subscription_purchase_mode() == "tariff" + + def is_subscription_custom_mode(self) -> bool: + return self.get_subscription_purchase_mode() == "custom" + + def is_subscription_fixed_mode(self) -> bool: + return self.get_subscription_purchase_mode() == "fixed" + def get_fixed_traffic_limit(self) -> int: return self.FIXED_TRAFFIC_LIMIT_GB diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 97a23b3c..34948855 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -22,10 +22,13 @@ logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) - .limit(1) + .limit(1) ) subscription = result.scalar_one_or_none() @@ -74,13 +77,14 @@ async def create_paid_subscription( db: AsyncSession, user_id: int, duration_days: int, - traffic_limit_gb: int = 0, + traffic_limit_gb: int = 0, device_limit: int = 1, - connected_squads: List[str] = None + connected_squads: List[str] = None, + tariff_id: Optional[int] = None, ) -> Subscription: - + end_date = datetime.utcnow() + timedelta(days=duration_days) - + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, @@ -89,7 +93,8 @@ async def create_paid_subscription( end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=connected_squads or [] + connected_squads=connected_squads or [], + tariff_id=tariff_id, ) db.add(subscription) @@ -266,7 +271,10 @@ async def get_expiring_subscriptions( result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, @@ -282,7 +290,10 @@ async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, @@ -298,12 +309,15 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, - Subscription.is_trial == False + Subscription.is_trial == False ) ) ) @@ -449,7 +463,10 @@ async def get_all_subscriptions( result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .order_by(Subscription.created_at.desc()) .offset(offset) .limit(limit) @@ -745,6 +762,7 @@ async def get_subscription_renewal_cost( select(Subscription) .options( selectinload(Subscription.user).selectinload(User.promo_group), + selectinload(Subscription.tariff), ) .where(Subscription.id == subscription_id) ) diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py new file mode 100644 index 00000000..d28b1207 --- /dev/null +++ b/app/database/crud/tariff.py @@ -0,0 +1,266 @@ +import logging +from typing import Iterable, List, Optional, Sequence + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + PromoGroup, + ServerSquad, + Subscription, + SubscriptionTariff, + SubscriptionTariffPrice, +) + +logger = logging.getLogger(__name__) + + +async def list_tariffs( + db: AsyncSession, + *, + include_inactive: bool = False, +) -> List[SubscriptionTariff]: + query = ( + select(SubscriptionTariff) + .options( + selectinload(SubscriptionTariff.promo_groups), + selectinload(SubscriptionTariff.server_squads), + selectinload(SubscriptionTariff.prices), + ) + .order_by(SubscriptionTariff.sort_order, SubscriptionTariff.name) + ) + + if not include_inactive: + query = query.where(SubscriptionTariff.is_active.is_(True)) + + result = await db.execute(query) + return result.scalars().unique().all() + + +async def get_tariff_by_id( + db: AsyncSession, + tariff_id: int, + *, + include_inactive: bool = False, +) -> Optional[SubscriptionTariff]: + query = ( + select(SubscriptionTariff) + .options( + selectinload(SubscriptionTariff.promo_groups), + selectinload(SubscriptionTariff.server_squads), + selectinload(SubscriptionTariff.prices), + ) + .where(SubscriptionTariff.id == tariff_id) + ) + + if not include_inactive: + query = query.where(SubscriptionTariff.is_active.is_(True)) + + result = await db.execute(query) + return result.scalars().unique().one_or_none() + + +async def _resolve_servers( + db: AsyncSession, + server_uuids: Sequence[str], +) -> List[ServerSquad]: + if not server_uuids: + return [] + + seen = set() + normalized: List[str] = [] + for raw_uuid in server_uuids: + if not raw_uuid: + continue + cleaned = raw_uuid.strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + normalized.append(cleaned) + if not normalized: + return [] + + result = await db.execute( + select(ServerSquad) + .options(selectinload(ServerSquad.allowed_promo_groups)) + .where(ServerSquad.squad_uuid.in_(normalized)) + ) + servers = result.scalars().unique().all() + + missing = set(normalized) - {server.squad_uuid for server in servers} + if missing: + logger.warning("Не найдены серверы для тарифов: %s", ", ".join(sorted(missing))) + + ordered_servers = sorted( + servers, + key=lambda server: normalized.index(server.squad_uuid) if server.squad_uuid in normalized else len(normalized), + ) + return ordered_servers + + +async def _resolve_promo_groups( + db: AsyncSession, + promo_group_ids: Optional[Iterable[int]], +) -> List[PromoGroup]: + if promo_group_ids is None: + return [] + + normalized = [int(pg_id) for pg_id in {int(pg_id) for pg_id in promo_group_ids}] + if not normalized: + return [] + + result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(normalized))) + promo_groups = result.scalars().all() + + missing = set(normalized) - {group.id for group in promo_groups} + if missing: + logger.warning("Не найдены промогруппы для тарифов: %s", ", ".join(map(str, sorted(missing)))) + + return promo_groups + + +def _normalize_prices(prices: Iterable[dict]) -> List[SubscriptionTariffPrice]: + unique_periods = {} + for price in prices or []: + try: + period = int(price.get('period_days')) + amount = int(price.get('price_kopeks')) + except (TypeError, ValueError): + continue + + if period <= 0 or amount < 0: + continue + + unique_periods[period] = amount + + normalized = [ + SubscriptionTariffPrice(period_days=period, price_kopeks=amount) + for period, amount in sorted(unique_periods.items()) + ] + return normalized + + +async def create_tariff( + db: AsyncSession, + *, + name: str, + description: Optional[str] = None, + traffic_limit_gb: int = 0, + device_limit: int = 1, + server_uuids: Sequence[str] = (), + promo_group_ids: Optional[Iterable[int]] = None, + prices: Iterable[dict] = (), + is_active: bool = True, + sort_order: int = 0, +) -> SubscriptionTariff: + servers = await _resolve_servers(db, server_uuids) + promo_groups = await _resolve_promo_groups(db, promo_group_ids) + price_models = _normalize_prices(prices) + + tariff = SubscriptionTariff( + name=name.strip(), + description=description, + traffic_limit_gb=max(0, int(traffic_limit_gb or 0)), + device_limit=max(1, int(device_limit or 1)), + is_active=bool(is_active), + sort_order=int(sort_order or 0), + server_squads=servers, + promo_groups=promo_groups, + prices=price_models, + ) + + db.add(tariff) + await db.commit() + await db.refresh(tariff) + + logger.info("Создан тариф '%s' (ID: %s)", tariff.name, tariff.id) + return tariff + + +async def update_tariff( + db: AsyncSession, + tariff_id: int, + *, + name: Optional[str] = None, + description: Optional[str] = None, + traffic_limit_gb: Optional[int] = None, + device_limit: Optional[int] = None, + server_uuids: Optional[Sequence[str]] = None, + promo_group_ids: Optional[Iterable[int]] = None, + prices: Optional[Iterable[dict]] = None, + is_active: Optional[bool] = None, + sort_order: Optional[int] = None, +) -> Optional[SubscriptionTariff]: + tariff = await get_tariff_by_id(db, tariff_id, include_inactive=True) + if not tariff: + return None + + if name is not None: + tariff.name = name.strip() + if description is not None: + tariff.description = description + if traffic_limit_gb is not None: + tariff.traffic_limit_gb = max(0, int(traffic_limit_gb)) + if device_limit is not None: + tariff.device_limit = max(1, int(device_limit)) + if is_active is not None: + tariff.is_active = bool(is_active) + if sort_order is not None: + tariff.sort_order = int(sort_order) + + if server_uuids is not None: + tariff.server_squads = await _resolve_servers(db, server_uuids) + if promo_group_ids is not None: + tariff.promo_groups = await _resolve_promo_groups(db, promo_group_ids) + if prices is not None: + tariff.prices = _normalize_prices(prices) + + await db.commit() + await db.refresh(tariff) + logger.info("Обновлен тариф '%s' (ID: %s)", tariff.name, tariff.id) + return tariff + + +async def delete_tariff(db: AsyncSession, tariff_id: int) -> bool: + result = await db.execute( + select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id) + ) + active_subscriptions = result.scalar() or 0 + if active_subscriptions > 0: + logger.warning( + "Нельзя удалить тариф %s: %s подписок использует его", + tariff_id, + active_subscriptions, + ) + return False + + await db.execute( + delete(SubscriptionTariff).where(SubscriptionTariff.id == tariff_id) + ) + await db.commit() + logger.info("Удален тариф ID %s", tariff_id) + return True + + +async def get_active_tariffs_for_promo_group( + db: AsyncSession, + promo_group_id: Optional[int], +) -> List[SubscriptionTariff]: + tariffs = await list_tariffs(db, include_inactive=False) + result = [] + for tariff in tariffs: + if not tariff.is_available_for_promo_group(promo_group_id): + continue + if not tariff.server_squads: + continue + available_servers = [ + server + for server in tariff.server_squads + if server.is_available and not server.is_full + ] + if not available_servers: + continue + tariff.server_squads = available_servers + result.append(tariff) + return result diff --git a/app/database/models.py b/app/database/models.py index 26c4f860..c88962e3 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -43,6 +43,42 @@ server_squad_promo_groups = Table( ) +subscription_tariff_promo_groups = Table( + "subscription_tariff_promo_groups", + Base.metadata, + Column( + "tariff_id", + Integer, + ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "promo_group_id", + Integer, + ForeignKey("promo_groups.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + +subscription_tariff_server_squads = Table( + "subscription_tariff_server_squads", + Base.metadata, + Column( + "tariff_id", + Integer, + ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "server_squad_id", + Integer, + ForeignKey("server_squads.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -305,6 +341,13 @@ class PromoGroup(Base): lazy="selectin", ) + tariffs = relationship( + "SubscriptionTariff", + secondary=subscription_tariff_promo_groups, + back_populates="promo_groups", + lazy="selectin", + ) + def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -440,12 +483,14 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - + connected_squads = Column(JSON, default=list) - + + tariff_id = Column(Integer, ForeignKey("subscription_tariffs.id", ondelete="SET NULL"), nullable=True) + autopay_enabled = Column(Boolean, default=False) autopay_days_before = Column(Integer, default=3) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -453,6 +498,7 @@ class Subscription(Base): user = relationship("User", back_populates="subscription") discount_offers = relationship("DiscountOffer", back_populates="subscription") + tariff = relationship("SubscriptionTariff", back_populates="subscriptions") @property def is_active(self) -> bool: @@ -870,6 +916,13 @@ class ServerSquad(Base): back_populates="server_squads", lazy="selectin", ) + + tariffs = relationship( + "SubscriptionTariff", + secondary=subscription_tariff_server_squads, + back_populates="server_squads", + lazy="selectin", + ) @property def price_rubles(self) -> float: @@ -891,9 +944,75 @@ class ServerSquad(Base): return "Доступен" +class SubscriptionTariff(Base): + __tablename__ = "subscription_tariffs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), unique=True, nullable=False) + description = Column(Text, nullable=True) + traffic_limit_gb = Column(Integer, nullable=False, default=0) + device_limit = Column(Integer, nullable=False, default=1) + is_active = Column(Boolean, nullable=False, default=True) + sort_order = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + promo_groups = relationship( + "PromoGroup", + secondary=subscription_tariff_promo_groups, + back_populates="tariffs", + lazy="selectin", + ) + server_squads = relationship( + "ServerSquad", + secondary=subscription_tariff_server_squads, + back_populates="tariffs", + lazy="selectin", + ) + prices = relationship( + "SubscriptionTariffPrice", + back_populates="tariff", + cascade="all, delete-orphan", + order_by="SubscriptionTariffPrice.period_days", + ) + subscriptions = relationship("Subscription", back_populates="tariff") + + def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool: + if not self.promo_groups: + return True + if promo_group_id is None: + return False + return any(pg.id == promo_group_id for pg in self.promo_groups if pg is not None) + + def get_price_for_period(self, period_days: int) -> Optional[int]: + for price in self.prices: + if price.period_days == period_days: + return price.price_kopeks + return None + + def get_server_uuids(self) -> List[str]: + return [server.squad_uuid for server in self.server_squads if server and server.squad_uuid] + + +class SubscriptionTariffPrice(Base): + __tablename__ = "subscription_tariff_prices" + __table_args__ = ( + UniqueConstraint("tariff_id", "period_days", name="uq_tariff_period_price"), + ) + + id = Column(Integer, primary_key=True, index=True) + tariff_id = Column(Integer, ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), nullable=False) + period_days = Column(Integer, nullable=False) + price_kopeks = Column(Integer, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + tariff = relationship("SubscriptionTariff", back_populates="prices") + + class SubscriptionServer(Base): __tablename__ = "subscription_servers" - + id = Column(Integer, primary_key=True, index=True) subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 52800062..cdbd944c 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -40,7 +40,9 @@ from app.keyboards.inline import ( get_happ_download_button_row, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_insufficient_balance_keyboard_with_cart, + get_tariff_selection_keyboard, + get_tariff_period_keyboard, ) from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService @@ -52,6 +54,7 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.services.subscription_service import SubscriptionService +from app.services.tariff_service import TariffService from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( @@ -441,6 +444,68 @@ def _build_subscription_period_prompt(db_user: User, texts) -> str: return f"{base_text}\n\n{promo_text}\n" +def _build_tariff_selection_prompt(texts) -> str: + return texts.t( + "TARIFF_SELECTION_PROMPT", + ( + "Выберите тариф\n\n" + "Доступные тарифы ниже зависят от вашей промогруппы и доступных серверов." + ), + ) + + +def _build_tariff_details_text(tariff, texts, language: str) -> str: + description = tariff.description or texts.t("TARIFF_NO_DESCRIPTION", "Описание отсутствует") + server_names = [server.display_name for server in tariff.server_squads] + servers_text = "\n".join(f"• {name}" for name in server_names) if server_names else texts.t( + "TARIFF_NO_SERVERS", "Нет доступных серверов" + ) + traffic_text = texts.format_traffic(tariff.traffic_limit_gb) + devices_text = texts.t("TARIFF_DEVICE_LIMIT", "{count} устройств").format(count=tariff.device_limit) + + return texts.t( + "TARIFF_DETAILS_TEMPLATE", + ( + "{name}\n\n" + "{description}\n\n" + "🌐 Серверы:\n{servers}\n\n" + "📊 Трафик: {traffic}\n" + "📱 Устройства: {devices}\n\n" + "Выберите срок действия тарифа:"" + ), + ).format( + name=tariff.name, + description=description, + servers=servers_text, + traffic=traffic_text, + devices=devices_text, + ) + + +def _build_tariff_summary_text(tariff, period_days: int, price_kopeks: int, texts, language: str) -> str: + period_text = format_period_description(period_days, language) + traffic_text = texts.format_traffic(tariff.traffic_limit_gb) + devices_text = texts.t("TARIFF_DEVICE_LIMIT", "{count} устройств").format(count=tariff.device_limit) + + return texts.t( + "TARIFF_CONFIRMATION_TEMPLATE", + ( + "{name}\n\n" + "📅 Период: {period}\n" + "📊 Трафик: {traffic}\n" + "📱 Устройства: {devices}\n" + "💰 Стоимость: {price}\n\n" + "Подтвердить покупку тарифа?" + ), + ).format( + name=tariff.name, + period=period_text, + traffic=traffic_text, + devices=devices_text, + price=texts.format_price(price_kopeks), + ) + + async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -449,6 +514,17 @@ async def show_subscription_info( await db.refresh(db_user) texts = get_texts(db_user.language) + + if settings.is_subscription_tariff_mode(): + await callback.answer( + texts.t( + "TARIFF_DEVICE_MANAGEMENT_DISABLED", + "ℹ️ Изменение количества устройств недоступно при использовании тарифов", + ), + show_alert=True, + ) + return + subscription = db_user.subscription if not subscription: @@ -1032,10 +1108,38 @@ async def activate_trial( async def start_subscription_purchase( callback: types.CallbackQuery, state: FSMContext, - db_user: User + db_user: User, + db: AsyncSession ): texts = get_texts(db_user.language) + if settings.is_subscription_tariff_mode(): + service = TariffService() + tariffs = await service.get_available_tariffs(db, db_user) + + await state.clear() + + if not tariffs: + await callback.message.edit_text( + texts.t( + "NO_TARIFFS_AVAILABLE", + "❌ Доступных тарифов не найдено. Пожалуйста, обратитесь в поддержку.", + ), + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML", + ) + else: + await state.set_state(SubscriptionStates.selecting_tariff) + await state.update_data({'tariff_mode': True}) + await callback.message.edit_text( + _build_tariff_selection_prompt(texts), + reply_markup=get_tariff_selection_keyboard(tariffs, db_user.language), + parse_mode="HTML", + ) + + await callback.answer() + return + await callback.message.edit_text( _build_subscription_period_prompt(db_user, texts), reply_markup=get_subscription_period_keyboard(db_user.language) @@ -1064,6 +1168,187 @@ async def start_subscription_purchase( await callback.answer() +async def handle_tariff_selection( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + if not settings.is_subscription_tariff_mode(): + await callback.answer() + return + + try: + tariff_id = int(callback.data.split('_')[2]) + except (IndexError, ValueError): + await callback.answer("❌ Некорректный тариф", show_alert=True) + return + + texts = get_texts(db_user.language) + service = TariffService() + tariff = await service.get_tariff_for_user(db, tariff_id, db_user) + + if not tariff or not getattr(tariff, "prices", None): + await callback.answer( + texts.t("TARIFF_UNAVAILABLE", "⚠️ Этот тариф сейчас недоступен"), + show_alert=True, + ) + return + + await state.update_data({ + 'tariff_mode': True, + 'selected_tariff_id': tariff.id, + }) + + await state.set_state(SubscriptionStates.selecting_tariff_period) + await callback.message.edit_text( + _build_tariff_details_text(tariff, texts, db_user.language), + reply_markup=get_tariff_period_keyboard(tariff, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_tariff_back( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + if not settings.is_subscription_tariff_mode(): + await callback.answer() + return + + texts = get_texts(db_user.language) + service = TariffService() + tariffs = await service.get_available_tariffs(db, db_user) + + if not tariffs: + await callback.message.edit_text( + texts.t( + "NO_TARIFFS_AVAILABLE", + "❌ Доступных тарифов не найдено. Пожалуйста, обратитесь в поддержку.", + ), + reply_markup=get_back_keyboard(db_user.language), + parse_mode="HTML", + ) + await state.clear() + await callback.answer() + return + + await state.set_state(SubscriptionStates.selecting_tariff) + await state.update_data({'tariff_mode': True}) + await callback.message.edit_text( + _build_tariff_selection_prompt(texts), + reply_markup=get_tariff_selection_keyboard(tariffs, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_tariff_period_selection( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + if not settings.is_subscription_tariff_mode(): + await callback.answer() + return + + try: + _, _, tariff_id_str, period_str = callback.data.split('_') + tariff_id = int(tariff_id_str) + period_days = int(period_str) + except (ValueError, IndexError): + await callback.answer("❌ Некорректный срок тарифа", show_alert=True) + return + + texts = get_texts(db_user.language) + service = TariffService() + tariff = await service.get_tariff_for_user(db, tariff_id, db_user) + + if not tariff: + await callback.answer( + texts.t("TARIFF_UNAVAILABLE", "⚠️ Этот тариф сейчас недоступен"), + show_alert=True, + ) + return + + price_kopeks = tariff.get_price_for_period(period_days) + if price_kopeks is None: + await callback.answer( + texts.t("TARIFF_PERIOD_UNAVAILABLE", "⚠️ Для этого тарифа нет указанного периода"), + show_alert=True, + ) + return + + server_uuids = tariff.get_server_uuids() + summary_text = _build_tariff_summary_text(tariff, period_days, price_kopeks, texts, db_user.language) + + months_in_period = calculate_months_from_days(period_days) + + state_payload = { + 'tariff_mode': True, + 'tariff_id': tariff.id, + 'tariff_name': tariff.name, + 'period_days': period_days, + 'total_price': price_kopeks, + 'base_price': price_kopeks, + 'base_price_original': price_kopeks, + 'base_discount_percent': 0, + 'base_discount_total': 0, + 'traffic_gb': tariff.traffic_limit_gb, + 'traffic_price_per_month': 0, + 'traffic_discount_percent': 0, + 'traffic_discount_total': 0, + 'traffic_discounted_price_per_month': 0, + 'total_traffic_price': 0, + 'devices': tariff.device_limit, + 'devices_price_per_month': 0, + 'devices_discount_percent': 0, + 'devices_discount_total': 0, + 'devices_discounted_price_per_month': 0, + 'total_devices_price': 0, + 'countries': server_uuids, + 'servers_price_per_month': 0, + 'servers_discount_percent': 0, + 'servers_discount_total': 0, + 'servers_discounted_price_per_month': 0, + 'server_prices_for_period': [0 for _ in server_uuids], + 'total_servers_price': 0, + 'discounted_monthly_additions': 0, + 'months_in_period': months_in_period, + } + + await state.set_data(state_payload) + await state.set_state(SubscriptionStates.confirming_purchase) + + await callback.message.edit_text( + summary_text, + reply_markup=get_subscription_confirm_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_change_tariff( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession +): + if not settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_MODE_DISABLED", "ℹ️ Смена тарифа недоступна в текущем режиме"), + show_alert=True, + ) + return + + await start_subscription_purchase(callback, state, db_user, db) + + async def save_cart_and_redirect_to_topup( callback: types.CallbackQuery, state: FSMContext, @@ -1166,6 +1451,14 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), + show_alert=True, + ) + return + if not await _should_show_countries_management(db_user): texts = get_texts(db_user.language) await callback.answer( @@ -1725,6 +2018,17 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "TARIFF_DEVICE_MANAGEMENT_DISABLED", + "ℹ️ Изменение количества устройств недоступно при использовании тарифов", + ), + show_alert=True, + ) + return + new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1868,6 +2172,17 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "TARIFF_DEVICE_MANAGEMENT_DISABLED", + "ℹ️ Изменение количества устройств недоступно при использовании тарифов", + ), + show_alert=True, + ) + return + callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) @@ -2473,6 +2788,14 @@ async def handle_reset_traffic( ): from app.config import settings + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True) return @@ -2930,6 +3253,14 @@ async def confirm_reset_traffic( ): from app.config import settings + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -3134,6 +3465,17 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + tariff_name = None + if getattr(subscription, 'tariff_id', None): + try: + await db.refresh(subscription, attribute_names=["tariff"]) + except Exception: + pass + + tariff = getattr(subscription, "tariff", None) + if tariff: + tariff_name = tariff.name + if subscription.is_trial: status_text = "🎁 Тестовая" type_text = "Триал" @@ -3173,6 +3515,9 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" + if tariff_name: + info_text += f"\n📦 Тариф: {tariff_name}" + if ( subscription_url and subscription_url != "Генерируется..." @@ -3245,6 +3590,14 @@ async def select_country( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), + show_alert=True, + ) + return + country_uuid = callback.data.split('_')[1] data = await state.get_data() @@ -3300,6 +3653,14 @@ async def countries_continue( state: FSMContext, db_user: User ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), + show_alert=True, + ) + return + data = await state.get_data() texts = get_texts(db_user.language) @@ -3724,6 +4085,7 @@ async def confirm_purchase( existing_subscription.traffic_limit_gb = final_traffic_gb existing_subscription.device_limit = data['devices'] existing_subscription.connected_squads = data['countries'] + existing_subscription.tariff_id = data.get('tariff_id') existing_subscription.start_date = current_time existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period @@ -3743,7 +4105,8 @@ async def confirm_purchase( duration_days=data['period_days'], device_limit=data['devices'], connected_squads=data['countries'], - traffic_gb=final_traffic_gb + traffic_gb=final_traffic_gb, + tariff_id=data.get('tariff_id'), ) from app.utils.user_utils import mark_user_as_had_paid_subscription @@ -3989,6 +4352,14 @@ async def add_traffic( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -4122,7 +4493,8 @@ async def create_paid_subscription_with_traffic_mode( duration_days: int, device_limit: int, connected_squads: List[str], - traffic_gb: Optional[int] = None + traffic_gb: Optional[int] = None, + tariff_id: Optional[int] = None, ): from app.config import settings @@ -4140,7 +4512,8 @@ async def create_paid_subscription_with_traffic_mode( duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=connected_squads + connected_squads=connected_squads, + tariff_id=tariff_id, ) logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") @@ -4178,11 +4551,39 @@ async def handle_subscription_settings( devices_used = await get_current_devices_count(db_user) + tariff_line = "" + if settings.is_subscription_tariff_mode(): + tariff_name = None + try: + await db.refresh(subscription, attribute_names=["tariff"]) + except Exception: + pass + + tariff = getattr(subscription, "tariff", None) + if tariff: + tariff_name = tariff.name + elif getattr(subscription, "tariff_id", None): + try: + from app.database.crud.tariff import get_tariff_by_id + + tariff_model = await get_tariff_by_id(db, subscription.tariff_id, include_inactive=True) + if tariff_model: + tariff_name = tariff_model.name + except Exception: + tariff_name = None + + if tariff_name: + tariff_line = texts.t( + "SUBSCRIPTION_SETTINGS_TARIFF_LINE", + "📦 Тариф: {tariff}\n", + ).format(tariff=tariff_name) + settings_text = texts.t( "SUBSCRIPTION_SETTINGS_OVERVIEW", ( "⚙️ Настройки подписки\n\n" "📊 Текущие параметры:\n" + "{tariff_line}" "🌐 Стран: {countries_count}\n" "📈 Трафик: {traffic_used} / {traffic_limit}\n" "📱 Устройства: {devices_used} / {devices_limit}\n\n" @@ -4194,6 +4595,7 @@ async def handle_subscription_settings( traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), devices_used=devices_used, devices_limit=subscription.device_limit, + tariff_line=tariff_line, ) show_countries = await _should_show_countries_management(db_user) @@ -4556,6 +4958,9 @@ async def handle_add_country_to_subscription( async def _should_show_countries_management(user: Optional[User] = None) -> bool: + if settings.is_subscription_tariff_mode(): + return False + try: promo_group_id = user.promo_group_id if user else None @@ -4597,6 +5002,14 @@ async def confirm_add_countries_to_subscription( db: AsyncSession, state: FSMContext ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), + show_alert=True, + ) + return + data = await state.get_data() texts = get_texts(db_user.language) subscription = db_user.subscription @@ -5592,6 +6005,14 @@ async def handle_switch_traffic( ): from app.config import settings + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -5635,6 +6056,14 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -5748,6 +6177,14 @@ async def execute_switch_traffic( db_user: User, db: AsyncSession ): + if settings.is_subscription_tariff_mode(): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), + show_alert=True, + ) + return + callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) price_difference = int(callback_parts[4]) @@ -5952,6 +6389,26 @@ def register_handlers(dp: Dispatcher): F.data.in_(["menu_buy", "subscription_upgrade"]) ) + dp.callback_query.register( + handle_tariff_selection, + F.data.startswith("tariff_select_") + ) + + dp.callback_query.register( + handle_tariff_period_selection, + F.data.startswith("tariff_period_") + ) + + dp.callback_query.register( + handle_tariff_back, + F.data == "tariff_back" + ) + + dp.callback_query.register( + handle_change_tariff, + F.data == "subscription_change_tariff" + ) + dp.callback_query.register( handle_add_countries, F.data == "subscription_add_countries" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index f2f89624..e1de21c2 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Iterable, List, Optional, Sequence from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -635,7 +635,7 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + available_periods = settings.get_available_subscription_periods() period_texts = { @@ -659,14 +659,91 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") ]) - + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_tariff_selection_keyboard( + tariffs: Sequence["SubscriptionTariff"], + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard: List[List[InlineKeyboardButton]] = [] + + for tariff in tariffs: + if not getattr(tariff, "prices", None): + continue + + price_values = [price.price_kopeks for price in tariff.prices if price.price_kopeks is not None] + if price_values: + min_price = min(price_values) + button_text = texts.t( + "TARIFF_SELECT_BUTTON", + "{name} • от {price}", + ).format(name=tariff.name, price=texts.format_price(min_price)) + else: + button_text = tariff.name + + keyboard.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_select_{tariff.id}", + ) + ]) + + if not keyboard: + keyboard.append([ + InlineKeyboardButton( + text=texts.t("NO_TARIFFS_AVAILABLE", "❌ Тарифы недоступны"), + callback_data="no_tariffs", + ) + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_tariff_period_keyboard( + tariff: "SubscriptionTariff", + language: str = DEFAULT_LANGUAGE, +) -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard: List[List[InlineKeyboardButton]] = [] + + sorted_prices = sorted( + getattr(tariff, "prices", []), + key=lambda price: price.period_days, + ) + + for price in sorted_prices: + period_text = format_period_description(price.period_days, language) + button_text = texts.t( + "TARIFF_PERIOD_BUTTON", + "{period} • {price}", + ).format(period=period_text, price=texts.format_price(price.price_kopeks)) + + keyboard.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_period_{tariff.id}_{price.period_days}", + ) + ]) + + keyboard.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="tariff_back"), + InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel"), + ]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: import logging logger = logging.getLogger(__name__) - + from app.config import settings if settings.is_traffic_fixed(): @@ -1762,10 +1839,34 @@ def get_devices_management_keyboard( def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup: from app.config import settings - + texts = get_texts(language) + if settings.is_subscription_tariff_mode(): + keyboard = [ + [ + InlineKeyboardButton( + text=texts.t("CHANGE_TARIFF_BUTTON", "🔁 Сменить тариф"), + callback_data="subscription_change_tariff", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), + callback_data="subscription_manage_devices", + ) + ], + [ + InlineKeyboardButton( + text=texts.t("RESET_ALL_DEVICES_BUTTON", "🔄 Сбросить все устройства"), + callback_data="reset_all_devices", + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")], + ] + return InlineKeyboardMarkup(inline_keyboard=keyboard) + keyboard = [] - + if show_countries_management: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") @@ -1773,7 +1874,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, keyboard.extend([ [ - InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices") + InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices") ], [ InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices") @@ -1787,11 +1888,11 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, keyboard.insert(-2, [ InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic") ]) - + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/services/tariff_service.py b/app/services/tariff_service.py new file mode 100644 index 00000000..b78484c3 --- /dev/null +++ b/app/services/tariff_service.py @@ -0,0 +1,45 @@ +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.tariff import ( + get_active_tariffs_for_promo_group, + get_tariff_by_id, +) +from app.database.models import SubscriptionTariff, User + + +class TariffService: + @staticmethod + async def get_available_tariffs( + db: AsyncSession, + user: Optional[User], + ) -> List[SubscriptionTariff]: + promo_group_id = getattr(user, "promo_group_id", None) if user else None + tariffs = await get_active_tariffs_for_promo_group(db, promo_group_id) + return sorted(tariffs, key=lambda tariff: (tariff.sort_order, tariff.id)) + + @staticmethod + async def get_tariff_for_user( + db: AsyncSession, + tariff_id: int, + user: Optional[User], + ) -> Optional[SubscriptionTariff]: + promo_group_id = getattr(user, "promo_group_id", None) if user else None + tariff = await get_tariff_by_id(db, tariff_id, include_inactive=False) + if not tariff: + return None + + available_servers = [ + server + for server in tariff.server_squads + if server.is_available and not server.is_full + ] + if not available_servers: + return None + + if not tariff.is_available_for_promo_group(promo_group_id): + return None + + tariff.server_squads = available_servers + return tariff diff --git a/app/states.py b/app/states.py index 43655b35..dac39fea 100644 --- a/app/states.py +++ b/app/states.py @@ -6,6 +6,8 @@ class RegistrationStates(StatesGroup): waiting_for_referral_code = State() class SubscriptionStates(StatesGroup): + selecting_tariff = State() + selecting_tariff_period = State() selecting_period = State() selecting_traffic = State() selecting_countries = State() diff --git a/app/webapi/app.py b/app/webapi/app.py index 0761ecad..db2d54fd 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -17,6 +17,7 @@ from .routes import ( remnawave, stats, subscriptions, + tariffs, tickets, tokens, transactions, @@ -45,6 +46,10 @@ OPENAPI_TAGS = [ "name": "subscriptions", "description": "Создание, продление и настройка подписок бота.", }, + { + "name": "tariffs", + "description": "Управление преднастроенными тарифами подписок.", + }, { "name": "support", "description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.", @@ -101,6 +106,7 @@ def create_web_api_app() -> FastAPI: app.include_router(config.router, prefix="/settings", tags=["settings"]) app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"]) + app.include_router(tariffs.router, prefix="/tariffs", tags=["tariffs"]) app.include_router(tickets.router, prefix="/tickets", tags=["support"]) app.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 31aef685..ed548ae9 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -5,6 +5,7 @@ from . import ( remnawave, stats, subscriptions, + tariffs, tickets, tokens, transactions, @@ -18,6 +19,7 @@ __all__ = [ "remnawave", "stats", "subscriptions", + "tariffs", "tickets", "tokens", "transactions", diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index 2ec77fe2..01ab9a88 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -50,6 +50,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse: subscription_url=subscription.subscription_url, subscription_crypto_link=subscription.subscription_crypto_link, connected_squads=list(subscription.connected_squads or []), + tariff_id=subscription.tariff_id, created_at=subscription.created_at, updated_at=subscription.updated_at, ) @@ -58,7 +59,10 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse: async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where(Subscription.id == subscription_id) ) subscription = result.scalar_one_or_none() @@ -77,7 +81,10 @@ async def list_subscriptions( user_id: Optional[int] = Query(default=None), is_trial: Optional[bool] = Query(default=None), ) -> list[SubscriptionResponse]: - query = select(Subscription).options(selectinload(Subscription.user)) + query = select(Subscription).options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) if status_filter: query = query.where(Subscription.status == status_filter.value) @@ -131,6 +138,7 @@ async def create_subscription( traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT, connected_squads=payload.connected_squads or [], + tariff_id=payload.tariff_id, ) subscription = await _get_subscription(db, subscription.id) diff --git a/app/webapi/routes/tariffs.py b/app/webapi/routes/tariffs.py new file mode 100644 index 00000000..36aa20f6 --- /dev/null +++ b/app/webapi/routes/tariffs.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.tariff import ( + create_tariff, + delete_tariff, + get_tariff_by_id, + list_tariffs, + update_tariff, +) +from app.database.models import SubscriptionTariff + +from ..dependencies import get_db_session, require_api_token +from ..schemas.tariffs import ( + TariffCreateRequest, + TariffResponse, + TariffUpdateRequest, + TariffPricePayload, +) + +router = APIRouter() + + +def _serialize_tariff(tariff: SubscriptionTariff) -> TariffResponse: + return TariffResponse( + id=tariff.id, + name=tariff.name, + description=tariff.description, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + is_active=tariff.is_active, + sort_order=tariff.sort_order, + server_squads=[server.squad_uuid for server in tariff.server_squads if getattr(server, "squad_uuid", None)], + promo_group_ids=[group.id for group in tariff.promo_groups if group is not None], + prices=[ + TariffPricePayload(period_days=price.period_days, price_kopeks=price.price_kopeks) + for price in sorted(tariff.prices, key=lambda item: item.period_days) + ], + created_at=tariff.created_at, + updated_at=tariff.updated_at, + ) + + +@router.get("", response_model=list[TariffResponse], tags=["tariffs"]) +async def list_subscription_tariffs( + include_inactive: bool = False, + _: str = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> list[TariffResponse]: + tariffs = await list_tariffs(db, include_inactive=include_inactive) + return [_serialize_tariff(tariff) for tariff in tariffs] + + +@router.get("/{tariff_id}", response_model=TariffResponse, tags=["tariffs"]) +async def get_subscription_tariff( + tariff_id: int, + _: str = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TariffResponse: + tariff = await get_tariff_by_id(db, tariff_id, include_inactive=True) + if not tariff: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff not found") + return _serialize_tariff(tariff) + + +@router.post("", response_model=TariffResponse, status_code=status.HTTP_201_CREATED, tags=["tariffs"]) +async def create_subscription_tariff( + payload: TariffCreateRequest, + _: str = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TariffResponse: + tariff = await create_tariff( + db, + name=payload.name, + description=payload.description, + traffic_limit_gb=payload.traffic_limit_gb, + device_limit=payload.device_limit, + server_uuids=payload.server_squads, + promo_group_ids=payload.promo_group_ids, + prices=[price.model_dump() for price in payload.prices], + is_active=payload.is_active, + sort_order=payload.sort_order, + ) + return _serialize_tariff(tariff) + + +@router.put("/{tariff_id}", response_model=TariffResponse, tags=["tariffs"]) +async def update_subscription_tariff( + tariff_id: int, + payload: TariffUpdateRequest, + _: str = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> TariffResponse: + tariff = await update_tariff( + db, + tariff_id, + name=payload.name, + description=payload.description, + traffic_limit_gb=payload.traffic_limit_gb, + device_limit=payload.device_limit, + server_uuids=payload.server_squads, + promo_group_ids=payload.promo_group_ids, + prices=[price.model_dump() for price in payload.prices] if payload.prices is not None else None, + is_active=payload.is_active, + sort_order=payload.sort_order, + ) + if not tariff: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff not found") + return _serialize_tariff(tariff) + + +@router.delete("/{tariff_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["tariffs"]) +async def delete_subscription_tariff( + tariff_id: int, + _: str = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> None: + success = await delete_tariff(db, tariff_id) + if not success: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unable to delete tariff") + + return None diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py index f09b5405..08691023 100644 --- a/app/webapi/schemas/subscriptions.py +++ b/app/webapi/schemas/subscriptions.py @@ -22,6 +22,7 @@ class SubscriptionResponse(BaseModel): subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None connected_squads: List[str] = Field(default_factory=list) + tariff_id: Optional[int] = None created_at: datetime updated_at: datetime @@ -34,6 +35,7 @@ class SubscriptionCreateRequest(BaseModel): device_limit: Optional[int] = None squad_uuid: Optional[str] = None connected_squads: Optional[List[str]] = None + tariff_id: Optional[int] = None class SubscriptionExtendRequest(BaseModel): diff --git a/app/webapi/schemas/tariffs.py b/app/webapi/schemas/tariffs.py new file mode 100644 index 00000000..1885ff9d --- /dev/null +++ b/app/webapi/schemas/tariffs.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class TariffPricePayload(BaseModel): + period_days: int = Field(..., gt=0) + price_kopeks: int = Field(..., ge=0) + + +class TariffResponse(BaseModel): + id: int + name: str + description: Optional[str] = None + traffic_limit_gb: int + device_limit: int + is_active: bool + sort_order: int + server_squads: List[str] = Field(default_factory=list) + promo_group_ids: List[int] = Field(default_factory=list) + prices: List[TariffPricePayload] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class TariffCreateRequest(BaseModel): + name: str + description: Optional[str] = None + traffic_limit_gb: int = Field(..., ge=0) + device_limit: int = Field(..., ge=1) + is_active: bool = True + sort_order: int = 0 + server_squads: List[str] = Field(default_factory=list) + promo_group_ids: Optional[List[int]] = None + prices: List[TariffPricePayload] = Field(default_factory=list) + + +class TariffUpdateRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + traffic_limit_gb: Optional[int] = Field(default=None, ge=0) + device_limit: Optional[int] = Field(default=None, ge=1) + is_active: Optional[bool] = None + sort_order: Optional[int] = None + server_squads: Optional[List[str]] = None + promo_group_ids: Optional[List[int]] = None + prices: Optional[List[TariffPricePayload]] = None From 60d23beddeb35345a03e461a61b915cf8768ab48 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:06:18 +0300 Subject: [PATCH 23/24] Revert "Add tariff-based subscription mode" --- app/config.py | 18 +- app/database/crud/subscription.py | 42 +-- app/database/crud/tariff.py | 266 ---------------- app/database/models.py | 127 +------- app/handlers/subscription.py | 467 +--------------------------- app/keyboards/inline.py | 119 +------ app/services/tariff_service.py | 45 --- app/states.py | 2 - app/webapi/app.py | 6 - app/webapi/routes/__init__.py | 2 - app/webapi/routes/subscriptions.py | 12 +- app/webapi/routes/tariffs.py | 122 -------- app/webapi/schemas/subscriptions.py | 2 - app/webapi/schemas/tariffs.py | 48 --- 14 files changed, 34 insertions(+), 1244 deletions(-) delete mode 100644 app/database/crud/tariff.py delete mode 100644 app/services/tariff_service.py delete mode 100644 app/webapi/routes/tariffs.py delete mode 100644 app/webapi/schemas/tariffs.py diff --git a/app/config.py b/app/config.py index 7a8c275e..a2cbaf80 100644 --- a/app/config.py +++ b/app/config.py @@ -117,8 +117,7 @@ class Settings(BaseSettings): BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = "" - TRAFFIC_SELECTION_MODE: str = "selectable" - SUBSCRIPTION_PURCHASE_MODE: str = "custom" + TRAFFIC_SELECTION_MODE: str = "selectable" FIXED_TRAFFIC_LIMIT_GB: int = 100 REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000 @@ -528,20 +527,7 @@ class Settings(BaseSettings): def is_traffic_fixed(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" - - def get_subscription_purchase_mode(self) -> str: - mode = (self.SUBSCRIPTION_PURCHASE_MODE or "custom").strip().lower() - return mode or "custom" - - def is_subscription_tariff_mode(self) -> bool: - return self.get_subscription_purchase_mode() == "tariff" - - def is_subscription_custom_mode(self) -> bool: - return self.get_subscription_purchase_mode() == "custom" - - def is_subscription_fixed_mode(self) -> bool: - return self.get_subscription_purchase_mode() == "fixed" - + def get_fixed_traffic_limit(self) -> int: return self.FIXED_TRAFFIC_LIMIT_GB diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 34948855..97a23b3c 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -22,13 +22,10 @@ logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) - .limit(1) + .limit(1) ) subscription = result.scalar_one_or_none() @@ -77,14 +74,13 @@ async def create_paid_subscription( db: AsyncSession, user_id: int, duration_days: int, - traffic_limit_gb: int = 0, + traffic_limit_gb: int = 0, device_limit: int = 1, - connected_squads: List[str] = None, - tariff_id: Optional[int] = None, + connected_squads: List[str] = None ) -> Subscription: - + end_date = datetime.utcnow() + timedelta(days=duration_days) - + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, @@ -93,8 +89,7 @@ async def create_paid_subscription( end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=connected_squads or [], - tariff_id=tariff_id, + connected_squads=connected_squads or [] ) db.add(subscription) @@ -271,10 +266,7 @@ async def get_expiring_subscriptions( result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, @@ -290,10 +282,7 @@ async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, @@ -309,15 +298,12 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, - Subscription.is_trial == False + Subscription.is_trial == False ) ) ) @@ -463,10 +449,7 @@ async def get_all_subscriptions( result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .order_by(Subscription.created_at.desc()) .offset(offset) .limit(limit) @@ -762,7 +745,6 @@ async def get_subscription_renewal_cost( select(Subscription) .options( selectinload(Subscription.user).selectinload(User.promo_group), - selectinload(Subscription.tariff), ) .where(Subscription.id == subscription_id) ) diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py deleted file mode 100644 index d28b1207..00000000 --- a/app/database/crud/tariff.py +++ /dev/null @@ -1,266 +0,0 @@ -import logging -from typing import Iterable, List, Optional, Sequence - -from sqlalchemy import delete, func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - PromoGroup, - ServerSquad, - Subscription, - SubscriptionTariff, - SubscriptionTariffPrice, -) - -logger = logging.getLogger(__name__) - - -async def list_tariffs( - db: AsyncSession, - *, - include_inactive: bool = False, -) -> List[SubscriptionTariff]: - query = ( - select(SubscriptionTariff) - .options( - selectinload(SubscriptionTariff.promo_groups), - selectinload(SubscriptionTariff.server_squads), - selectinload(SubscriptionTariff.prices), - ) - .order_by(SubscriptionTariff.sort_order, SubscriptionTariff.name) - ) - - if not include_inactive: - query = query.where(SubscriptionTariff.is_active.is_(True)) - - result = await db.execute(query) - return result.scalars().unique().all() - - -async def get_tariff_by_id( - db: AsyncSession, - tariff_id: int, - *, - include_inactive: bool = False, -) -> Optional[SubscriptionTariff]: - query = ( - select(SubscriptionTariff) - .options( - selectinload(SubscriptionTariff.promo_groups), - selectinload(SubscriptionTariff.server_squads), - selectinload(SubscriptionTariff.prices), - ) - .where(SubscriptionTariff.id == tariff_id) - ) - - if not include_inactive: - query = query.where(SubscriptionTariff.is_active.is_(True)) - - result = await db.execute(query) - return result.scalars().unique().one_or_none() - - -async def _resolve_servers( - db: AsyncSession, - server_uuids: Sequence[str], -) -> List[ServerSquad]: - if not server_uuids: - return [] - - seen = set() - normalized: List[str] = [] - for raw_uuid in server_uuids: - if not raw_uuid: - continue - cleaned = raw_uuid.strip() - if not cleaned or cleaned in seen: - continue - seen.add(cleaned) - normalized.append(cleaned) - if not normalized: - return [] - - result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.allowed_promo_groups)) - .where(ServerSquad.squad_uuid.in_(normalized)) - ) - servers = result.scalars().unique().all() - - missing = set(normalized) - {server.squad_uuid for server in servers} - if missing: - logger.warning("Не найдены серверы для тарифов: %s", ", ".join(sorted(missing))) - - ordered_servers = sorted( - servers, - key=lambda server: normalized.index(server.squad_uuid) if server.squad_uuid in normalized else len(normalized), - ) - return ordered_servers - - -async def _resolve_promo_groups( - db: AsyncSession, - promo_group_ids: Optional[Iterable[int]], -) -> List[PromoGroup]: - if promo_group_ids is None: - return [] - - normalized = [int(pg_id) for pg_id in {int(pg_id) for pg_id in promo_group_ids}] - if not normalized: - return [] - - result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(normalized))) - promo_groups = result.scalars().all() - - missing = set(normalized) - {group.id for group in promo_groups} - if missing: - logger.warning("Не найдены промогруппы для тарифов: %s", ", ".join(map(str, sorted(missing)))) - - return promo_groups - - -def _normalize_prices(prices: Iterable[dict]) -> List[SubscriptionTariffPrice]: - unique_periods = {} - for price in prices or []: - try: - period = int(price.get('period_days')) - amount = int(price.get('price_kopeks')) - except (TypeError, ValueError): - continue - - if period <= 0 or amount < 0: - continue - - unique_periods[period] = amount - - normalized = [ - SubscriptionTariffPrice(period_days=period, price_kopeks=amount) - for period, amount in sorted(unique_periods.items()) - ] - return normalized - - -async def create_tariff( - db: AsyncSession, - *, - name: str, - description: Optional[str] = None, - traffic_limit_gb: int = 0, - device_limit: int = 1, - server_uuids: Sequence[str] = (), - promo_group_ids: Optional[Iterable[int]] = None, - prices: Iterable[dict] = (), - is_active: bool = True, - sort_order: int = 0, -) -> SubscriptionTariff: - servers = await _resolve_servers(db, server_uuids) - promo_groups = await _resolve_promo_groups(db, promo_group_ids) - price_models = _normalize_prices(prices) - - tariff = SubscriptionTariff( - name=name.strip(), - description=description, - traffic_limit_gb=max(0, int(traffic_limit_gb or 0)), - device_limit=max(1, int(device_limit or 1)), - is_active=bool(is_active), - sort_order=int(sort_order or 0), - server_squads=servers, - promo_groups=promo_groups, - prices=price_models, - ) - - db.add(tariff) - await db.commit() - await db.refresh(tariff) - - logger.info("Создан тариф '%s' (ID: %s)", tariff.name, tariff.id) - return tariff - - -async def update_tariff( - db: AsyncSession, - tariff_id: int, - *, - name: Optional[str] = None, - description: Optional[str] = None, - traffic_limit_gb: Optional[int] = None, - device_limit: Optional[int] = None, - server_uuids: Optional[Sequence[str]] = None, - promo_group_ids: Optional[Iterable[int]] = None, - prices: Optional[Iterable[dict]] = None, - is_active: Optional[bool] = None, - sort_order: Optional[int] = None, -) -> Optional[SubscriptionTariff]: - tariff = await get_tariff_by_id(db, tariff_id, include_inactive=True) - if not tariff: - return None - - if name is not None: - tariff.name = name.strip() - if description is not None: - tariff.description = description - if traffic_limit_gb is not None: - tariff.traffic_limit_gb = max(0, int(traffic_limit_gb)) - if device_limit is not None: - tariff.device_limit = max(1, int(device_limit)) - if is_active is not None: - tariff.is_active = bool(is_active) - if sort_order is not None: - tariff.sort_order = int(sort_order) - - if server_uuids is not None: - tariff.server_squads = await _resolve_servers(db, server_uuids) - if promo_group_ids is not None: - tariff.promo_groups = await _resolve_promo_groups(db, promo_group_ids) - if prices is not None: - tariff.prices = _normalize_prices(prices) - - await db.commit() - await db.refresh(tariff) - logger.info("Обновлен тариф '%s' (ID: %s)", tariff.name, tariff.id) - return tariff - - -async def delete_tariff(db: AsyncSession, tariff_id: int) -> bool: - result = await db.execute( - select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id) - ) - active_subscriptions = result.scalar() or 0 - if active_subscriptions > 0: - logger.warning( - "Нельзя удалить тариф %s: %s подписок использует его", - tariff_id, - active_subscriptions, - ) - return False - - await db.execute( - delete(SubscriptionTariff).where(SubscriptionTariff.id == tariff_id) - ) - await db.commit() - logger.info("Удален тариф ID %s", tariff_id) - return True - - -async def get_active_tariffs_for_promo_group( - db: AsyncSession, - promo_group_id: Optional[int], -) -> List[SubscriptionTariff]: - tariffs = await list_tariffs(db, include_inactive=False) - result = [] - for tariff in tariffs: - if not tariff.is_available_for_promo_group(promo_group_id): - continue - if not tariff.server_squads: - continue - available_servers = [ - server - for server in tariff.server_squads - if server.is_available and not server.is_full - ] - if not available_servers: - continue - tariff.server_squads = available_servers - result.append(tariff) - return result diff --git a/app/database/models.py b/app/database/models.py index c88962e3..26c4f860 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -43,42 +43,6 @@ server_squad_promo_groups = Table( ) -subscription_tariff_promo_groups = Table( - "subscription_tariff_promo_groups", - Base.metadata, - Column( - "tariff_id", - Integer, - ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "promo_group_id", - Integer, - ForeignKey("promo_groups.id", ondelete="CASCADE"), - primary_key=True, - ), -) - - -subscription_tariff_server_squads = Table( - "subscription_tariff_server_squads", - Base.metadata, - Column( - "tariff_id", - Integer, - ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), - primary_key=True, - ), - Column( - "server_squad_id", - Integer, - ForeignKey("server_squads.id", ondelete="CASCADE"), - primary_key=True, - ), -) - - class UserStatus(Enum): ACTIVE = "active" BLOCKED = "blocked" @@ -341,13 +305,6 @@ class PromoGroup(Base): lazy="selectin", ) - tariffs = relationship( - "SubscriptionTariff", - secondary=subscription_tariff_promo_groups, - back_populates="promo_groups", - lazy="selectin", - ) - def _get_period_discounts_map(self) -> Dict[int, int]: raw_discounts = self.period_discounts or {} @@ -483,14 +440,12 @@ class Subscription(Base): subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) - + connected_squads = Column(JSON, default=list) - - tariff_id = Column(Integer, ForeignKey("subscription_tariffs.id", ondelete="SET NULL"), nullable=True) - + autopay_enabled = Column(Boolean, default=False) autopay_days_before = Column(Integer, default=3) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -498,7 +453,6 @@ class Subscription(Base): user = relationship("User", back_populates="subscription") discount_offers = relationship("DiscountOffer", back_populates="subscription") - tariff = relationship("SubscriptionTariff", back_populates="subscriptions") @property def is_active(self) -> bool: @@ -916,13 +870,6 @@ class ServerSquad(Base): back_populates="server_squads", lazy="selectin", ) - - tariffs = relationship( - "SubscriptionTariff", - secondary=subscription_tariff_server_squads, - back_populates="server_squads", - lazy="selectin", - ) @property def price_rubles(self) -> float: @@ -944,75 +891,9 @@ class ServerSquad(Base): return "Доступен" -class SubscriptionTariff(Base): - __tablename__ = "subscription_tariffs" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), unique=True, nullable=False) - description = Column(Text, nullable=True) - traffic_limit_gb = Column(Integer, nullable=False, default=0) - device_limit = Column(Integer, nullable=False, default=1) - is_active = Column(Boolean, nullable=False, default=True) - sort_order = Column(Integer, nullable=False, default=0) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - promo_groups = relationship( - "PromoGroup", - secondary=subscription_tariff_promo_groups, - back_populates="tariffs", - lazy="selectin", - ) - server_squads = relationship( - "ServerSquad", - secondary=subscription_tariff_server_squads, - back_populates="tariffs", - lazy="selectin", - ) - prices = relationship( - "SubscriptionTariffPrice", - back_populates="tariff", - cascade="all, delete-orphan", - order_by="SubscriptionTariffPrice.period_days", - ) - subscriptions = relationship("Subscription", back_populates="tariff") - - def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool: - if not self.promo_groups: - return True - if promo_group_id is None: - return False - return any(pg.id == promo_group_id for pg in self.promo_groups if pg is not None) - - def get_price_for_period(self, period_days: int) -> Optional[int]: - for price in self.prices: - if price.period_days == period_days: - return price.price_kopeks - return None - - def get_server_uuids(self) -> List[str]: - return [server.squad_uuid for server in self.server_squads if server and server.squad_uuid] - - -class SubscriptionTariffPrice(Base): - __tablename__ = "subscription_tariff_prices" - __table_args__ = ( - UniqueConstraint("tariff_id", "period_days", name="uq_tariff_period_price"), - ) - - id = Column(Integer, primary_key=True, index=True) - tariff_id = Column(Integer, ForeignKey("subscription_tariffs.id", ondelete="CASCADE"), nullable=False) - period_days = Column(Integer, nullable=False) - price_kopeks = Column(Integer, nullable=False) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - tariff = relationship("SubscriptionTariff", back_populates="prices") - - class SubscriptionServer(Base): __tablename__ = "subscription_servers" - + id = Column(Integer, primary_key=True, index=True) subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index cdbd944c..52800062 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -40,9 +40,7 @@ from app.keyboards.inline import ( get_happ_download_button_row, get_payment_methods_keyboard_with_cart, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart, - get_tariff_selection_keyboard, - get_tariff_period_keyboard, + get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService @@ -54,7 +52,6 @@ from app.services.subscription_checkout_service import ( should_offer_checkout_resume, ) from app.services.subscription_service import SubscriptionService -from app.services.tariff_service import TariffService from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( @@ -444,68 +441,6 @@ def _build_subscription_period_prompt(db_user: User, texts) -> str: return f"{base_text}\n\n{promo_text}\n" -def _build_tariff_selection_prompt(texts) -> str: - return texts.t( - "TARIFF_SELECTION_PROMPT", - ( - "Выберите тариф\n\n" - "Доступные тарифы ниже зависят от вашей промогруппы и доступных серверов." - ), - ) - - -def _build_tariff_details_text(tariff, texts, language: str) -> str: - description = tariff.description or texts.t("TARIFF_NO_DESCRIPTION", "Описание отсутствует") - server_names = [server.display_name for server in tariff.server_squads] - servers_text = "\n".join(f"• {name}" for name in server_names) if server_names else texts.t( - "TARIFF_NO_SERVERS", "Нет доступных серверов" - ) - traffic_text = texts.format_traffic(tariff.traffic_limit_gb) - devices_text = texts.t("TARIFF_DEVICE_LIMIT", "{count} устройств").format(count=tariff.device_limit) - - return texts.t( - "TARIFF_DETAILS_TEMPLATE", - ( - "{name}\n\n" - "{description}\n\n" - "🌐 Серверы:\n{servers}\n\n" - "📊 Трафик: {traffic}\n" - "📱 Устройства: {devices}\n\n" - "Выберите срок действия тарифа:"" - ), - ).format( - name=tariff.name, - description=description, - servers=servers_text, - traffic=traffic_text, - devices=devices_text, - ) - - -def _build_tariff_summary_text(tariff, period_days: int, price_kopeks: int, texts, language: str) -> str: - period_text = format_period_description(period_days, language) - traffic_text = texts.format_traffic(tariff.traffic_limit_gb) - devices_text = texts.t("TARIFF_DEVICE_LIMIT", "{count} устройств").format(count=tariff.device_limit) - - return texts.t( - "TARIFF_CONFIRMATION_TEMPLATE", - ( - "{name}\n\n" - "📅 Период: {period}\n" - "📊 Трафик: {traffic}\n" - "📱 Устройства: {devices}\n" - "💰 Стоимость: {price}\n\n" - "Подтвердить покупку тарифа?" - ), - ).format( - name=tariff.name, - period=period_text, - traffic=traffic_text, - devices=devices_text, - price=texts.format_price(price_kopeks), - ) - - async def show_subscription_info( callback: types.CallbackQuery, db_user: User, @@ -514,17 +449,6 @@ async def show_subscription_info( await db.refresh(db_user) texts = get_texts(db_user.language) - - if settings.is_subscription_tariff_mode(): - await callback.answer( - texts.t( - "TARIFF_DEVICE_MANAGEMENT_DISABLED", - "ℹ️ Изменение количества устройств недоступно при использовании тарифов", - ), - show_alert=True, - ) - return - subscription = db_user.subscription if not subscription: @@ -1108,38 +1032,10 @@ async def activate_trial( async def start_subscription_purchase( callback: types.CallbackQuery, state: FSMContext, - db_user: User, - db: AsyncSession + db_user: User ): texts = get_texts(db_user.language) - if settings.is_subscription_tariff_mode(): - service = TariffService() - tariffs = await service.get_available_tariffs(db, db_user) - - await state.clear() - - if not tariffs: - await callback.message.edit_text( - texts.t( - "NO_TARIFFS_AVAILABLE", - "❌ Доступных тарифов не найдено. Пожалуйста, обратитесь в поддержку.", - ), - reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", - ) - else: - await state.set_state(SubscriptionStates.selecting_tariff) - await state.update_data({'tariff_mode': True}) - await callback.message.edit_text( - _build_tariff_selection_prompt(texts), - reply_markup=get_tariff_selection_keyboard(tariffs, db_user.language), - parse_mode="HTML", - ) - - await callback.answer() - return - await callback.message.edit_text( _build_subscription_period_prompt(db_user, texts), reply_markup=get_subscription_period_keyboard(db_user.language) @@ -1168,187 +1064,6 @@ async def start_subscription_purchase( await callback.answer() -async def handle_tariff_selection( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if not settings.is_subscription_tariff_mode(): - await callback.answer() - return - - try: - tariff_id = int(callback.data.split('_')[2]) - except (IndexError, ValueError): - await callback.answer("❌ Некорректный тариф", show_alert=True) - return - - texts = get_texts(db_user.language) - service = TariffService() - tariff = await service.get_tariff_for_user(db, tariff_id, db_user) - - if not tariff or not getattr(tariff, "prices", None): - await callback.answer( - texts.t("TARIFF_UNAVAILABLE", "⚠️ Этот тариф сейчас недоступен"), - show_alert=True, - ) - return - - await state.update_data({ - 'tariff_mode': True, - 'selected_tariff_id': tariff.id, - }) - - await state.set_state(SubscriptionStates.selecting_tariff_period) - await callback.message.edit_text( - _build_tariff_details_text(tariff, texts, db_user.language), - reply_markup=get_tariff_period_keyboard(tariff, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_tariff_back( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if not settings.is_subscription_tariff_mode(): - await callback.answer() - return - - texts = get_texts(db_user.language) - service = TariffService() - tariffs = await service.get_available_tariffs(db, db_user) - - if not tariffs: - await callback.message.edit_text( - texts.t( - "NO_TARIFFS_AVAILABLE", - "❌ Доступных тарифов не найдено. Пожалуйста, обратитесь в поддержку.", - ), - reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", - ) - await state.clear() - await callback.answer() - return - - await state.set_state(SubscriptionStates.selecting_tariff) - await state.update_data({'tariff_mode': True}) - await callback.message.edit_text( - _build_tariff_selection_prompt(texts), - reply_markup=get_tariff_selection_keyboard(tariffs, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_tariff_period_selection( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if not settings.is_subscription_tariff_mode(): - await callback.answer() - return - - try: - _, _, tariff_id_str, period_str = callback.data.split('_') - tariff_id = int(tariff_id_str) - period_days = int(period_str) - except (ValueError, IndexError): - await callback.answer("❌ Некорректный срок тарифа", show_alert=True) - return - - texts = get_texts(db_user.language) - service = TariffService() - tariff = await service.get_tariff_for_user(db, tariff_id, db_user) - - if not tariff: - await callback.answer( - texts.t("TARIFF_UNAVAILABLE", "⚠️ Этот тариф сейчас недоступен"), - show_alert=True, - ) - return - - price_kopeks = tariff.get_price_for_period(period_days) - if price_kopeks is None: - await callback.answer( - texts.t("TARIFF_PERIOD_UNAVAILABLE", "⚠️ Для этого тарифа нет указанного периода"), - show_alert=True, - ) - return - - server_uuids = tariff.get_server_uuids() - summary_text = _build_tariff_summary_text(tariff, period_days, price_kopeks, texts, db_user.language) - - months_in_period = calculate_months_from_days(period_days) - - state_payload = { - 'tariff_mode': True, - 'tariff_id': tariff.id, - 'tariff_name': tariff.name, - 'period_days': period_days, - 'total_price': price_kopeks, - 'base_price': price_kopeks, - 'base_price_original': price_kopeks, - 'base_discount_percent': 0, - 'base_discount_total': 0, - 'traffic_gb': tariff.traffic_limit_gb, - 'traffic_price_per_month': 0, - 'traffic_discount_percent': 0, - 'traffic_discount_total': 0, - 'traffic_discounted_price_per_month': 0, - 'total_traffic_price': 0, - 'devices': tariff.device_limit, - 'devices_price_per_month': 0, - 'devices_discount_percent': 0, - 'devices_discount_total': 0, - 'devices_discounted_price_per_month': 0, - 'total_devices_price': 0, - 'countries': server_uuids, - 'servers_price_per_month': 0, - 'servers_discount_percent': 0, - 'servers_discount_total': 0, - 'servers_discounted_price_per_month': 0, - 'server_prices_for_period': [0 for _ in server_uuids], - 'total_servers_price': 0, - 'discounted_monthly_additions': 0, - 'months_in_period': months_in_period, - } - - await state.set_data(state_payload) - await state.set_state(SubscriptionStates.confirming_purchase) - - await callback.message.edit_text( - summary_text, - reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_change_tariff( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if not settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_MODE_DISABLED", "ℹ️ Смена тарифа недоступна в текущем режиме"), - show_alert=True, - ) - return - - await start_subscription_purchase(callback, state, db_user, db) - - async def save_cart_and_redirect_to_topup( callback: types.CallbackQuery, state: FSMContext, @@ -1451,14 +1166,6 @@ async def handle_add_countries( db: AsyncSession, state: FSMContext ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), - show_alert=True, - ) - return - if not await _should_show_countries_management(db_user): texts = get_texts(db_user.language) await callback.answer( @@ -2018,17 +1725,6 @@ async def confirm_change_devices( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t( - "TARIFF_DEVICE_MANAGEMENT_DISABLED", - "ℹ️ Изменение количества устройств недоступно при использовании тарифов", - ), - show_alert=True, - ) - return - new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -2172,17 +1868,6 @@ async def execute_change_devices( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t( - "TARIFF_DEVICE_MANAGEMENT_DISABLED", - "ℹ️ Изменение количества устройств недоступно при использовании тарифов", - ), - show_alert=True, - ) - return - callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) @@ -2788,14 +2473,6 @@ async def handle_reset_traffic( ): from app.config import settings - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True) return @@ -3253,14 +2930,6 @@ async def confirm_reset_traffic( ): from app.config import settings - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -3465,17 +3134,6 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." - tariff_name = None - if getattr(subscription, 'tariff_id', None): - try: - await db.refresh(subscription, attribute_names=["tariff"]) - except Exception: - pass - - tariff = getattr(subscription, "tariff", None) - if tariff: - tariff_name = tariff.name - if subscription.is_trial: status_text = "🎁 Тестовая" type_text = "Триал" @@ -3515,9 +3173,6 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" - if tariff_name: - info_text += f"\n📦 Тариф: {tariff_name}" - if ( subscription_url and subscription_url != "Генерируется..." @@ -3590,14 +3245,6 @@ async def select_country( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), - show_alert=True, - ) - return - country_uuid = callback.data.split('_')[1] data = await state.get_data() @@ -3653,14 +3300,6 @@ async def countries_continue( state: FSMContext, db_user: User ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), - show_alert=True, - ) - return - data = await state.get_data() texts = get_texts(db_user.language) @@ -4085,7 +3724,6 @@ async def confirm_purchase( existing_subscription.traffic_limit_gb = final_traffic_gb existing_subscription.device_limit = data['devices'] existing_subscription.connected_squads = data['countries'] - existing_subscription.tariff_id = data.get('tariff_id') existing_subscription.start_date = current_time existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period @@ -4105,8 +3743,7 @@ async def confirm_purchase( duration_days=data['period_days'], device_limit=data['devices'], connected_squads=data['countries'], - traffic_gb=final_traffic_gb, - tariff_id=data.get('tariff_id'), + traffic_gb=final_traffic_gb ) from app.utils.user_utils import mark_user_as_had_paid_subscription @@ -4352,14 +3989,6 @@ async def add_traffic( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -4493,8 +4122,7 @@ async def create_paid_subscription_with_traffic_mode( duration_days: int, device_limit: int, connected_squads: List[str], - traffic_gb: Optional[int] = None, - tariff_id: Optional[int] = None, + traffic_gb: Optional[int] = None ): from app.config import settings @@ -4512,8 +4140,7 @@ async def create_paid_subscription_with_traffic_mode( duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=connected_squads, - tariff_id=tariff_id, + connected_squads=connected_squads ) logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") @@ -4551,39 +4178,11 @@ async def handle_subscription_settings( devices_used = await get_current_devices_count(db_user) - tariff_line = "" - if settings.is_subscription_tariff_mode(): - tariff_name = None - try: - await db.refresh(subscription, attribute_names=["tariff"]) - except Exception: - pass - - tariff = getattr(subscription, "tariff", None) - if tariff: - tariff_name = tariff.name - elif getattr(subscription, "tariff_id", None): - try: - from app.database.crud.tariff import get_tariff_by_id - - tariff_model = await get_tariff_by_id(db, subscription.tariff_id, include_inactive=True) - if tariff_model: - tariff_name = tariff_model.name - except Exception: - tariff_name = None - - if tariff_name: - tariff_line = texts.t( - "SUBSCRIPTION_SETTINGS_TARIFF_LINE", - "📦 Тариф: {tariff}\n", - ).format(tariff=tariff_name) - settings_text = texts.t( "SUBSCRIPTION_SETTINGS_OVERVIEW", ( "⚙️ Настройки подписки\n\n" "📊 Текущие параметры:\n" - "{tariff_line}" "🌐 Стран: {countries_count}\n" "📈 Трафик: {traffic_used} / {traffic_limit}\n" "📱 Устройства: {devices_used} / {devices_limit}\n\n" @@ -4595,7 +4194,6 @@ async def handle_subscription_settings( traffic_limit=texts.format_traffic(subscription.traffic_limit_gb), devices_used=devices_used, devices_limit=subscription.device_limit, - tariff_line=tariff_line, ) show_countries = await _should_show_countries_management(db_user) @@ -4958,9 +4556,6 @@ async def handle_add_country_to_subscription( async def _should_show_countries_management(user: Optional[User] = None) -> bool: - if settings.is_subscription_tariff_mode(): - return False - try: promo_group_id = user.promo_group_id if user else None @@ -5002,14 +4597,6 @@ async def confirm_add_countries_to_subscription( db: AsyncSession, state: FSMContext ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_COUNTRY_MANAGEMENT_DISABLED", "ℹ️ Смена серверов недоступна при использовании тарифов"), - show_alert=True, - ) - return - data = await state.get_data() texts = get_texts(db_user.language) subscription = db_user.subscription @@ -6005,14 +5592,6 @@ async def handle_switch_traffic( ): from app.config import settings - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - if settings.is_traffic_fixed(): await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) return @@ -6056,14 +5635,6 @@ async def confirm_switch_traffic( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -6177,14 +5748,6 @@ async def execute_switch_traffic( db_user: User, db: AsyncSession ): - if settings.is_subscription_tariff_mode(): - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TARIFF_TRAFFIC_MANAGEMENT_DISABLED", "ℹ️ Управление трафиком недоступно при использовании тарифов"), - show_alert=True, - ) - return - callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) price_difference = int(callback_parts[4]) @@ -6389,26 +5952,6 @@ def register_handlers(dp: Dispatcher): F.data.in_(["menu_buy", "subscription_upgrade"]) ) - dp.callback_query.register( - handle_tariff_selection, - F.data.startswith("tariff_select_") - ) - - dp.callback_query.register( - handle_tariff_period_selection, - F.data.startswith("tariff_period_") - ) - - dp.callback_query.register( - handle_tariff_back, - F.data == "tariff_back" - ) - - dp.callback_query.register( - handle_change_tariff, - F.data == "subscription_change_tariff" - ) - dp.callback_query.register( handle_add_countries, F.data == "subscription_add_countries" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index e1de21c2..f2f89624 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,4 +1,4 @@ -from typing import Iterable, List, Optional, Sequence +from typing import List, Optional from aiogram import types from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from datetime import datetime @@ -635,7 +635,7 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + available_periods = settings.get_available_subscription_periods() period_texts = { @@ -659,91 +659,14 @@ def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def get_tariff_selection_keyboard( - tariffs: Sequence["SubscriptionTariff"], - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - keyboard: List[List[InlineKeyboardButton]] = [] - - for tariff in tariffs: - if not getattr(tariff, "prices", None): - continue - - price_values = [price.price_kopeks for price in tariff.prices if price.price_kopeks is not None] - if price_values: - min_price = min(price_values) - button_text = texts.t( - "TARIFF_SELECT_BUTTON", - "{name} • от {price}", - ).format(name=tariff.name, price=texts.format_price(min_price)) - else: - button_text = tariff.name - - keyboard.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_select_{tariff.id}", - ) - ]) - - if not keyboard: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("NO_TARIFFS_AVAILABLE", "❌ Тарифы недоступны"), - callback_data="no_tariffs", - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def get_tariff_period_keyboard( - tariff: "SubscriptionTariff", - language: str = DEFAULT_LANGUAGE, -) -> InlineKeyboardMarkup: - texts = get_texts(language) - keyboard: List[List[InlineKeyboardButton]] = [] - - sorted_prices = sorted( - getattr(tariff, "prices", []), - key=lambda price: price.period_days, - ) - - for price in sorted_prices: - period_text = format_period_description(price.period_days, language) - button_text = texts.t( - "TARIFF_PERIOD_BUTTON", - "{period} • {price}", - ).format(period=period_text, price=texts.format_price(price.price_kopeks)) - - keyboard.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_period_{tariff.id}_{price.period_days}", - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="tariff_back"), - InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel"), - ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: import logging logger = logging.getLogger(__name__) - + from app.config import settings if settings.is_traffic_fixed(): @@ -1839,34 +1762,10 @@ def get_devices_management_keyboard( def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup: from app.config import settings - + texts = get_texts(language) - if settings.is_subscription_tariff_mode(): - keyboard = [ - [ - InlineKeyboardButton( - text=texts.t("CHANGE_TARIFF_BUTTON", "🔁 Сменить тариф"), - callback_data="subscription_change_tariff", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), - callback_data="subscription_manage_devices", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("RESET_ALL_DEVICES_BUTTON", "🔄 Сбросить все устройства"), - callback_data="reset_all_devices", - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")], - ] - return InlineKeyboardMarkup(inline_keyboard=keyboard) - keyboard = [] - + if show_countries_management: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") @@ -1874,7 +1773,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, keyboard.extend([ [ - InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices") + InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices") ], [ InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices") @@ -1888,11 +1787,11 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, keyboard.insert(-2, [ InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic") ]) - + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/services/tariff_service.py b/app/services/tariff_service.py deleted file mode 100644 index b78484c3..00000000 --- a/app/services/tariff_service.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import List, Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.tariff import ( - get_active_tariffs_for_promo_group, - get_tariff_by_id, -) -from app.database.models import SubscriptionTariff, User - - -class TariffService: - @staticmethod - async def get_available_tariffs( - db: AsyncSession, - user: Optional[User], - ) -> List[SubscriptionTariff]: - promo_group_id = getattr(user, "promo_group_id", None) if user else None - tariffs = await get_active_tariffs_for_promo_group(db, promo_group_id) - return sorted(tariffs, key=lambda tariff: (tariff.sort_order, tariff.id)) - - @staticmethod - async def get_tariff_for_user( - db: AsyncSession, - tariff_id: int, - user: Optional[User], - ) -> Optional[SubscriptionTariff]: - promo_group_id = getattr(user, "promo_group_id", None) if user else None - tariff = await get_tariff_by_id(db, tariff_id, include_inactive=False) - if not tariff: - return None - - available_servers = [ - server - for server in tariff.server_squads - if server.is_available and not server.is_full - ] - if not available_servers: - return None - - if not tariff.is_available_for_promo_group(promo_group_id): - return None - - tariff.server_squads = available_servers - return tariff diff --git a/app/states.py b/app/states.py index dac39fea..43655b35 100644 --- a/app/states.py +++ b/app/states.py @@ -6,8 +6,6 @@ class RegistrationStates(StatesGroup): waiting_for_referral_code = State() class SubscriptionStates(StatesGroup): - selecting_tariff = State() - selecting_tariff_period = State() selecting_period = State() selecting_traffic = State() selecting_countries = State() diff --git a/app/webapi/app.py b/app/webapi/app.py index db2d54fd..0761ecad 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -17,7 +17,6 @@ from .routes import ( remnawave, stats, subscriptions, - tariffs, tickets, tokens, transactions, @@ -46,10 +45,6 @@ OPENAPI_TAGS = [ "name": "subscriptions", "description": "Создание, продление и настройка подписок бота.", }, - { - "name": "tariffs", - "description": "Управление преднастроенными тарифами подписок.", - }, { "name": "support", "description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.", @@ -106,7 +101,6 @@ def create_web_api_app() -> FastAPI: app.include_router(config.router, prefix="/settings", tags=["settings"]) app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"]) - app.include_router(tariffs.router, prefix="/tariffs", tags=["tariffs"]) app.include_router(tickets.router, prefix="/tickets", tags=["support"]) app.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"]) diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index ed548ae9..31aef685 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -5,7 +5,6 @@ from . import ( remnawave, stats, subscriptions, - tariffs, tickets, tokens, transactions, @@ -19,7 +18,6 @@ __all__ = [ "remnawave", "stats", "subscriptions", - "tariffs", "tickets", "tokens", "transactions", diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index 01ab9a88..2ec77fe2 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -50,7 +50,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse: subscription_url=subscription.subscription_url, subscription_crypto_link=subscription.subscription_crypto_link, connected_squads=list(subscription.connected_squads or []), - tariff_id=subscription.tariff_id, created_at=subscription.created_at, updated_at=subscription.updated_at, ) @@ -59,10 +58,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse: async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription: result = await db.execute( select(Subscription) - .options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + .options(selectinload(Subscription.user)) .where(Subscription.id == subscription_id) ) subscription = result.scalar_one_or_none() @@ -81,10 +77,7 @@ async def list_subscriptions( user_id: Optional[int] = Query(default=None), is_trial: Optional[bool] = Query(default=None), ) -> list[SubscriptionResponse]: - query = select(Subscription).options( - selectinload(Subscription.user), - selectinload(Subscription.tariff), - ) + query = select(Subscription).options(selectinload(Subscription.user)) if status_filter: query = query.where(Subscription.status == status_filter.value) @@ -138,7 +131,6 @@ async def create_subscription( traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT, connected_squads=payload.connected_squads or [], - tariff_id=payload.tariff_id, ) subscription = await _get_subscription(db, subscription.id) diff --git a/app/webapi/routes/tariffs.py b/app/webapi/routes/tariffs.py deleted file mode 100644 index 36aa20f6..00000000 --- a/app/webapi/routes/tariffs.py +++ /dev/null @@ -1,122 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Security, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database.crud.tariff import ( - create_tariff, - delete_tariff, - get_tariff_by_id, - list_tariffs, - update_tariff, -) -from app.database.models import SubscriptionTariff - -from ..dependencies import get_db_session, require_api_token -from ..schemas.tariffs import ( - TariffCreateRequest, - TariffResponse, - TariffUpdateRequest, - TariffPricePayload, -) - -router = APIRouter() - - -def _serialize_tariff(tariff: SubscriptionTariff) -> TariffResponse: - return TariffResponse( - id=tariff.id, - name=tariff.name, - description=tariff.description, - traffic_limit_gb=tariff.traffic_limit_gb, - device_limit=tariff.device_limit, - is_active=tariff.is_active, - sort_order=tariff.sort_order, - server_squads=[server.squad_uuid for server in tariff.server_squads if getattr(server, "squad_uuid", None)], - promo_group_ids=[group.id for group in tariff.promo_groups if group is not None], - prices=[ - TariffPricePayload(period_days=price.period_days, price_kopeks=price.price_kopeks) - for price in sorted(tariff.prices, key=lambda item: item.period_days) - ], - created_at=tariff.created_at, - updated_at=tariff.updated_at, - ) - - -@router.get("", response_model=list[TariffResponse], tags=["tariffs"]) -async def list_subscription_tariffs( - include_inactive: bool = False, - _: str = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> list[TariffResponse]: - tariffs = await list_tariffs(db, include_inactive=include_inactive) - return [_serialize_tariff(tariff) for tariff in tariffs] - - -@router.get("/{tariff_id}", response_model=TariffResponse, tags=["tariffs"]) -async def get_subscription_tariff( - tariff_id: int, - _: str = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> TariffResponse: - tariff = await get_tariff_by_id(db, tariff_id, include_inactive=True) - if not tariff: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff not found") - return _serialize_tariff(tariff) - - -@router.post("", response_model=TariffResponse, status_code=status.HTTP_201_CREATED, tags=["tariffs"]) -async def create_subscription_tariff( - payload: TariffCreateRequest, - _: str = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> TariffResponse: - tariff = await create_tariff( - db, - name=payload.name, - description=payload.description, - traffic_limit_gb=payload.traffic_limit_gb, - device_limit=payload.device_limit, - server_uuids=payload.server_squads, - promo_group_ids=payload.promo_group_ids, - prices=[price.model_dump() for price in payload.prices], - is_active=payload.is_active, - sort_order=payload.sort_order, - ) - return _serialize_tariff(tariff) - - -@router.put("/{tariff_id}", response_model=TariffResponse, tags=["tariffs"]) -async def update_subscription_tariff( - tariff_id: int, - payload: TariffUpdateRequest, - _: str = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> TariffResponse: - tariff = await update_tariff( - db, - tariff_id, - name=payload.name, - description=payload.description, - traffic_limit_gb=payload.traffic_limit_gb, - device_limit=payload.device_limit, - server_uuids=payload.server_squads, - promo_group_ids=payload.promo_group_ids, - prices=[price.model_dump() for price in payload.prices] if payload.prices is not None else None, - is_active=payload.is_active, - sort_order=payload.sort_order, - ) - if not tariff: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff not found") - return _serialize_tariff(tariff) - - -@router.delete("/{tariff_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["tariffs"]) -async def delete_subscription_tariff( - tariff_id: int, - _: str = Security(require_api_token), - db: AsyncSession = Depends(get_db_session), -) -> None: - success = await delete_tariff(db, tariff_id) - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unable to delete tariff") - - return None diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py index 08691023..f09b5405 100644 --- a/app/webapi/schemas/subscriptions.py +++ b/app/webapi/schemas/subscriptions.py @@ -22,7 +22,6 @@ class SubscriptionResponse(BaseModel): subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None connected_squads: List[str] = Field(default_factory=list) - tariff_id: Optional[int] = None created_at: datetime updated_at: datetime @@ -35,7 +34,6 @@ class SubscriptionCreateRequest(BaseModel): device_limit: Optional[int] = None squad_uuid: Optional[str] = None connected_squads: Optional[List[str]] = None - tariff_id: Optional[int] = None class SubscriptionExtendRequest(BaseModel): diff --git a/app/webapi/schemas/tariffs.py b/app/webapi/schemas/tariffs.py deleted file mode 100644 index 1885ff9d..00000000 --- a/app/webapi/schemas/tariffs.py +++ /dev/null @@ -1,48 +0,0 @@ -from datetime import datetime -from typing import List, Optional - -from pydantic import BaseModel, Field - - -class TariffPricePayload(BaseModel): - period_days: int = Field(..., gt=0) - price_kopeks: int = Field(..., ge=0) - - -class TariffResponse(BaseModel): - id: int - name: str - description: Optional[str] = None - traffic_limit_gb: int - device_limit: int - is_active: bool - sort_order: int - server_squads: List[str] = Field(default_factory=list) - promo_group_ids: List[int] = Field(default_factory=list) - prices: List[TariffPricePayload] = Field(default_factory=list) - created_at: datetime - updated_at: datetime - - -class TariffCreateRequest(BaseModel): - name: str - description: Optional[str] = None - traffic_limit_gb: int = Field(..., ge=0) - device_limit: int = Field(..., ge=1) - is_active: bool = True - sort_order: int = 0 - server_squads: List[str] = Field(default_factory=list) - promo_group_ids: Optional[List[int]] = None - prices: List[TariffPricePayload] = Field(default_factory=list) - - -class TariffUpdateRequest(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - traffic_limit_gb: Optional[int] = Field(default=None, ge=0) - device_limit: Optional[int] = Field(default=None, ge=1) - is_active: Optional[bool] = None - sort_order: Optional[int] = None - server_squads: Optional[List[str]] = None - promo_group_ids: Optional[List[int]] = None - prices: Optional[List[TariffPricePayload]] = None From 1ed418b1764be72a5e8b05b8d8ef87de68940a69 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 1 Oct 2025 02:08:17 +0300 Subject: [PATCH 24/24] Delete miniapp/index.html --- miniapp/index.html | 724 --------------------------------------------- 1 file changed, 724 deletions(-) delete mode 100644 miniapp/index.html diff --git a/miniapp/index.html b/miniapp/index.html deleted file mode 100644 index 5ea8ab7f..00000000 --- a/miniapp/index.html +++ /dev/null @@ -1,724 +0,0 @@ - - - - - - - VPN Subscription - - - - -
- -
- -
Secure & Fast Connection
-
- - -
-
-
Loading your subscription...
-
- - - - - - -
- - - -