From f0bbe90f80b01ced7de8726014cbae39c6c05b3a Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 15:59:37 +0300 Subject: [PATCH 01/26] Add Docker build workflow and enhance admin functionalities - Introduced a new GitHub Actions workflow for building and pushing the development Docker image. - Added inline mode handling for user interactions, allowing users to share referral links and view statistics. - Enhanced admin functionalities with new sections for user management, statistics, and promo code management. - Implemented CSV export for logs and improved notification services for various events, including new user registrations and payment notifications. - Updated localization files to support new features and commands. --- .github/workflows/dev-docker-build.yml | 56 ++ bot/handlers/admin/common.py | 68 +- bot/handlers/admin/logs_admin.py | 106 +++ bot/handlers/admin/promo_codes.py | 219 +++++ bot/handlers/admin/statistics.py | 51 +- bot/handlers/admin/user_management.py | 1103 ++++++++++++----------- bot/handlers/inline_mode.py | 351 ++++++++ bot/handlers/user/payment.py | 18 +- bot/handlers/user/referral.py | 37 +- bot/handlers/user/start.py | 70 ++ bot/handlers/user/trial_handler.py | 99 +- bot/keyboards/inline/admin_keyboards.py | 103 ++- bot/keyboards/inline/user_keyboards.py | 3 + bot/main_bot.py | 4 + bot/services/notification_service.py | 205 ++++- bot/services/promo_code_service.py | 16 +- bot/states/admin_states.py | 6 + config/settings.py | 10 + db/dal/payment_dal.py | 64 ++ db/dal/promo_code_dal.py | 7 + db/dal/user_dal.py | 67 ++ locales/en.json | 78 +- locales/ru.json | 78 +- 23 files changed, 2270 insertions(+), 549 deletions(-) create mode 100644 .github/workflows/dev-docker-build.yml create mode 100644 bot/handlers/inline_mode.py diff --git a/.github/workflows/dev-docker-build.yml b/.github/workflows/dev-docker-build.yml new file mode 100644 index 0000000..eb0706f --- /dev/null +++ b/.github/workflows/dev-docker-build.yml @@ -0,0 +1,56 @@ +name: Build and Push Dev Docker Image + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + flavor: | + latest=false + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest + run: echo ${{ steps.meta.outputs.digest }} \ No newline at end of file diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index e23df9d..0592fdb 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -6,7 +6,11 @@ from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings -from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard +from bot.keyboards.inline.admin_keyboards import ( + get_admin_panel_keyboard, get_stats_monitoring_keyboard, + get_user_management_keyboard, get_ban_management_keyboard, + get_promo_marketing_keyboard, get_system_functions_keyboard +) from bot.middlewares.i18n import JsonI18n from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService @@ -75,6 +79,9 @@ async def admin_panel_actions_callback_handler( elif action == "create_promo": await admin_promo_handlers.create_promo_prompt_handler( callback, state, i18n_data, settings, session) + elif action == "create_bulk_promo": + await admin_promo_handlers.create_bulk_promo_prompt_handler( + callback, state, i18n_data, settings, session) elif action == "manage_promos": await admin_promo_handlers.manage_promo_codes_handler( callback, i18n_data, settings, session) @@ -87,6 +94,10 @@ async def admin_panel_actions_callback_handler( elif action == "unban_user_prompt": await admin_user_mgmnt_handlers.unban_user_prompt_handler( callback, state, i18n_data, settings, session) + elif action == "users_management": + from . import user_management as admin_user_management_handlers + await admin_user_management_handlers.user_management_menu_handler( + callback, state, i18n_data, settings, session) elif action == "view_banned": await admin_user_mgmnt_handlers.view_banned_users_handler( @@ -121,3 +132,58 @@ async def admin_panel_actions_callback_handler( f"Unknown admin_action received: {action} from callback {callback.data}" ) await callback.answer(_("admin_unknown_action"), show_alert=True) + + +@router.callback_query(F.data.startswith("admin_section:")) +async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext, + settings: Settings, i18n_data: dict, session: AsyncSession): + section = callback.data.split(":")[1] + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if not callback.message: + await callback.answer("Error: message context lost.", show_alert=True) + return + + try: + if section == "stats_monitoring": + await callback.message.edit_text( + _("admin_stats_and_monitoring_section"), + reply_markup=get_stats_monitoring_keyboard(i18n, current_lang) + ) + elif section == "user_management": + await callback.message.edit_text( + _("admin_user_management_section"), + reply_markup=get_user_management_keyboard(i18n, current_lang) + ) + elif section == "ban_management": + await callback.message.edit_text( + _("admin_ban_management_section"), + reply_markup=get_ban_management_keyboard(i18n, current_lang) + ) + elif section == "promo_marketing": + await callback.message.edit_text( + _("admin_promo_marketing_section"), + reply_markup=get_promo_marketing_keyboard(i18n, current_lang) + ) + elif section == "system_functions": + await callback.message.edit_text( + _("admin_system_functions_section"), + reply_markup=get_system_functions_keyboard(i18n, current_lang) + ) + else: + await callback.answer(_("admin_unknown_action"), show_alert=True) + return + + await callback.answer() + except Exception as e: + logging.error(f"Error handling admin section {section}: {e}") + await callback.message.answer( + _("error_occurred_try_again"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + await callback.answer() diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index 5f43c08..f12034b 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -1,6 +1,9 @@ import logging import math import re +import csv +import io +from datetime import datetime from aiogram import Router, F, types, Bot from aiogram.fsm.context import FSMContext from typing import Optional, List, Dict, Any @@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery, await state.clear() await display_logs_menu(callback, i18n_data, settings, session) + + +@router.callback_query(F.data == "admin_logs:export_csv") +async def export_logs_csv_handler(callback: types.CallbackQuery, + settings: Settings, i18n_data: dict, + session: AsyncSession): + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + if not i18n or not callback.message: + await callback.answer("Error processing CSV export.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + await callback.answer(_( + "admin_logs_csv_export_started", + default="🔄 Начинаю экспорт логов в CSV..." + )) + + try: + # Get all logs (limit to 10000 for performance) + logs_models = await message_log_dal.get_all_message_logs( + session, limit=10000, offset=0) + + if not logs_models: + await callback.message.answer(_( + "admin_logs_csv_no_data", + default="❌ Нет данных для экспорта" + )) + return + + # Create CSV content + csv_buffer = io.StringIO() + csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) + + # Write header + headers = [ + _("admin_csv_header_log_id", default="Log ID"), + _("admin_csv_header_timestamp", default="Timestamp"), + _("admin_csv_header_user_id", default="User ID"), + _("admin_csv_header_telegram_username", default="Telegram Username"), + _("admin_csv_header_telegram_first_name", default="Telegram First Name"), + _("admin_csv_header_event_type", default="Event Type"), + _("admin_csv_header_content", default="Content"), + _("admin_csv_header_is_admin_event", default="Is Admin Event"), + _("admin_csv_header_target_user_id", default="Target User ID"), + _("admin_csv_header_raw_update_preview", default="Raw Update Preview") + ] + csv_writer.writerow(headers) + + # Write data rows + for log in logs_models: + # Format timestamp + timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else '' + + # Clean content and raw_update_preview (remove newlines and quotes for CSV) + content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip() + raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip() + + row = [ + log.log_id or '', + timestamp_str, + log.user_id or '', + log.telegram_username or '', + log.telegram_first_name or '', + log.event_type or '', + content_clean, + 'Yes' if log.is_admin_event else 'No', + log.target_user_id or '', + raw_update_clean + ] + csv_writer.writerow(row) + + # Create file + csv_content = csv_buffer.getvalue() + csv_buffer.close() + + # Generate filename with current timestamp + now = datetime.now() + filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv" + + # Send as document + csv_file = types.BufferedInputFile( + csv_content.encode('utf-8-sig'), # BOM for Excel compatibility + filename=filename + ) + + await callback.message.answer_document( + csv_file, + caption=_( + "admin_logs_csv_export_success", + default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}", + count=len(logs_models), + date=now.strftime('%Y-%m-%d %H:%M:%S') + ) + ) + + except Exception as e: + logging.error(f"Error exporting logs to CSV: {e}", exc_info=True) + await callback.message.answer(_( + "admin_logs_csv_export_failed", + default="❌ Ошибка при экспорте логов: {error}", + error=str(e) + )) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index af4fecd..227d239 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1,4 +1,6 @@ import logging +import random +import string from aiogram import Router, F, types, Bot from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext @@ -340,6 +342,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, StateFilter( AdminStates.waiting_for_promo_details, AdminStates.waiting_for_promo_edit_details, + AdminStates.waiting_for_bulk_promo_details, ), ) async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, @@ -365,3 +368,219 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, await callback.answer(_("admin_action_cancelled_default_alert")) await state.clear() + + +async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing bulk promo creation.", + show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_bulk_promo_create_prompt", + default="📦 Массовое создание промокодов\n\nВведите данные в формате:\n<количество> <бонусные_дни> <максимальное_использование> [дни_действия]\n\nПример: 50 7 100 30\n(создаст 50 промокодов на 7 дней с лимитом 100 активаций каждый, действующих 30 дней)", + example_format="50 7 100 30" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + except Exception as e: + logging.warning( + f"Could not edit message for bulk promo prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + await callback.answer() + await state.set_state(AdminStates.waiting_for_bulk_promo_details) + + +def generate_unique_promo_code(length: int = 8) -> str: + """Generate a unique promotional code""" + characters = string.ascii_uppercase + string.digits + # Exclude confusing characters + characters = characters.replace('0', '').replace('O', '').replace('1', '').replace('I', '').replace('L', '') + return ''.join(random.choice(characters) for _ in range(length)) + + +@router.message(AdminStates.waiting_for_bulk_promo_details, F.text) +async def process_bulk_promo_details_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if not message.text: + await message.answer(_("admin_promo_invalid_format")) + return + + parts = message.text.strip().split() + if not (3 <= len(parts) <= 4): + await message.answer(_( + "admin_bulk_promo_invalid_format", + default="❌ Неверный формат. Используйте: <количество> <бонусные_дни> <максимальное_использование> [дни_действия]" + )) + return + + try: + count = int(parts[0]) + bonus_days = int(parts[1]) + max_activations = int(parts[2]) + + if count <= 0 or count > 1000: + raise ValueError(_("admin_bulk_promo_invalid_count", default="Количество должно быть от 1 до 1000")) + + if bonus_days <= 0 or max_activations <= 0: + raise ValueError(_("admin_promo_invalid_bonus_or_activations")) + + valid_until_date: Optional[datetime] = None + valid_until_str_display = _("admin_promo_valid_indefinitely") + + if len(parts) == 4: + valid_days_from_now = int(parts[3]) + if valid_days_from_now <= 0: + raise ValueError(_("admin_promo_invalid_validity_days")) + valid_until_date = datetime.now( + timezone.utc) + timedelta(days=valid_days_from_now) + valid_until_str_display = _( + "admin_promo_valid_until_display", + date=valid_until_date.strftime('%Y-%m-%d')) + + except ValueError as e: + await message.answer(_( + "admin_bulk_promo_invalid_values", + default="❌ Неверные значения: {error}", + error=str(e) + )) + return + except Exception as e_parse: + logging.error(f"Error parsing bulk promo details '{message.text}': {e_parse}") + await message.answer(_("admin_promo_invalid_format_general")) + return + + admin_id = message.from_user.id if message.from_user else 0 + + # Generate unique codes and create promo codes + created_codes = [] + failed_codes = [] + + await message.answer(_( + "admin_bulk_promo_creating", + default="🔄 Создаю {count} промокодов...", + count=count + )) + + for i in range(count): + try: + # Generate unique code + attempts = 0 + while attempts < 10: # Max 10 attempts to generate unique code + code = generate_unique_promo_code() + + # Check if code already exists + existing_promo = await promo_code_dal.get_promo_code_by_code(session, code) + if not existing_promo: + break + attempts += 1 + + if attempts >= 10: + failed_codes.append(f"Failed to generate unique code #{i+1}") + continue + + promo_data_to_create = { + "code": code, + "bonus_days": bonus_days, + "max_activations": max_activations, + "created_by_admin_id": admin_id, + "valid_until": valid_until_date, + "is_active": True, + "current_activations": 0 + } + + created_promo = await promo_code_dal.create_promo_code( + session, promo_data_to_create) + + if created_promo: + created_codes.append(created_promo.code) + else: + failed_codes.append(f"Failed to create code #{i+1}") + + except Exception as e: + logging.error(f"Error creating bulk promo code #{i+1}: {e}") + failed_codes.append(f"Error creating code #{i+1}: {str(e)}") + + try: + await session.commit() + + # Prepare success message + success_text_parts = [ + _( + "admin_bulk_promo_created_success", + default="✅ Массовое создание завершено!\n\n📦 Создано промокодов: {created_count}\n💎 Бонусных дней: {bonus_days}\n🔄 Макс. активаций каждого: {max_activations}\n⏰ Действительны до: {valid_until}", + created_count=len(created_codes), + bonus_days=bonus_days, + max_activations=max_activations, + valid_until=valid_until_str_display + ) + ] + + if failed_codes: + success_text_parts.append(f"\n❌ Ошибок: {len(failed_codes)}") + + if created_codes: + # Show first few codes as examples + codes_to_show = created_codes[:10] # Show first 10 + success_text_parts.append(f"\n📝 Примеры созданных кодов:") + success_text_parts.append("\n".join([f"• {code}" for code in codes_to_show])) + + if len(created_codes) > 10: + success_text_parts.append(f"... и еще {len(created_codes) - 10} кодов") + + # Send codes as a file if many were created + if len(created_codes) > 20: + try: + codes_text = "\n".join(created_codes) + codes_file = types.BufferedInputFile( + codes_text.encode('utf-8'), + filename=f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + ) + await message.answer_document( + codes_file, + caption=_( + "admin_bulk_promo_codes_file", + default="📄 Файл со всеми созданными промокодами" + ) + ) + except Exception as e: + logging.error(f"Failed to send codes file: {e}") + + final_text = "\n".join(success_text_parts) + await message.answer( + final_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + + except Exception as e_db_commit: + await session.rollback() + logging.error(f"Failed to commit bulk promo codes creation: {e_db_commit}", exc_info=True) + await message.answer(_( + "admin_bulk_promo_creation_failed", + default="❌ Ошибка при сохранении промокодов в базу данных" + )) + + await state.clear() + + + diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 616ea09..93d7b32 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -29,13 +29,52 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts = [f"{_('admin_stats_header')}"] - user_stats_dict = await user_dal.get_user_count_stats_dal(session) + # Enhanced user statistics + user_stats = await user_dal.get_enhanced_user_statistics(session) + stats_text_parts.append( - _("admin_stats_users", - total_users=user_stats_dict.get("total_users", 0), - banned_users=user_stats_dict.get("banned_users", 0), - active_subs=user_stats_dict.get("users_with_active_subscriptions", - 0))) + f"\n👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}" + ) + stats_text_parts.append( + f"📊 Всего: {user_stats['total_users']}" + ) + stats_text_parts.append( + f"📈 Активных сегодня: {user_stats['active_today']}" + ) + stats_text_parts.append( + f"💳 С платной подпиской: {user_stats['paid_subscriptions']}" + ) + stats_text_parts.append( + f"🆓 На пробном периоде: {user_stats['trial_users']}" + ) + stats_text_parts.append( + f"😴 Неактивных: {user_stats['inactive_users']}" + ) + stats_text_parts.append( + f"🚫 Заблокированных: {user_stats['banned_users']}" + ) + stats_text_parts.append( + f"🎁 Привлечено по реферальной программе: {user_stats['referral_users']}" + ) + + # Financial statistics + financial_stats = await payment_dal.get_financial_statistics(session) + + stats_text_parts.append( + f"\n💰 {_('admin_financial_stats_header', default='Финансовая статистика')}" + ) + stats_text_parts.append( + f"📅 За сегодня: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} платежей)" + ) + stats_text_parts.append( + f"📅 За неделю: {financial_stats['week_revenue']:.2f} RUB" + ) + stats_text_parts.append( + f"📅 За месяц: {financial_stats['month_revenue']:.2f} RUB" + ) + stats_text_parts.append( + f"🏆 За все время: {financial_stats['all_time_revenue']:.2f} RUB" + ) last_payments_models: List[ Payment] = await payment_dal.get_recent_payment_logs_with_user(session, diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index d98a3f2..16e7b2e 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -1,545 +1,644 @@ import logging import re -import math from aiogram import Router, F, types, Bot from aiogram.fsm.context import FSMContext -from typing import Optional, Tuple, List, Any - +from aiogram.utils.markdown import hcode, hbold +from typing import Optional, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession -from datetime import datetime +from datetime import datetime, timezone from config.settings import Settings - -from db.dal import user_dal, subscription_dal -from db.models import User, Subscription - -from bot.services.panel_api_service import PanelApiService - +from db.dal import user_dal, subscription_dal, message_log_dal +from db.models import User from bot.states.admin_states import AdminStates -from bot.keyboards.inline.admin_keyboards import ( - get_back_to_admin_panel_keyboard, get_user_card_keyboard, - get_banned_users_keyboard, get_confirmation_keyboard, - get_admin_panel_keyboard) +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard +from bot.services.subscription_service import SubscriptionService +from bot.services.panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton router = Router(name="admin_user_management_router") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") -async def _get_user_model_by_input(session: AsyncSession, - input_text: str) -> Optional[User]: +async def user_management_menu_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Display user management menu""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing user management.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_user_management_prompt", + default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + logging.warning(f"Could not edit message for user management: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_search) + + +def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder: + """Generate keyboard for user management actions""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + # Row 1: Trial and Subscription actions + builder.button( + text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"), + callback_data=f"user_action:reset_trial:{user_id}" + ) + builder.button( + text=_(key="admin_user_add_subscription_button", default="➕ Добавить дни"), + callback_data=f"user_action:add_subscription:{user_id}" + ) + + # Row 2: Block/Unblock and Message + builder.button( + text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"), + callback_data=f"user_action:toggle_ban:{user_id}" + ) + builder.button( + text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"), + callback_data=f"user_action:send_message:{user_id}" + ) + + # Row 3: View actions + builder.button( + text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"), + callback_data=f"user_action:view_logs:{user_id}" + ) + builder.button( + text=_(key="admin_user_refresh_button", default="🔄 Обновить"), + callback_data=f"user_action:refresh:{user_id}" + ) + + # Row 4: Back button + builder.button( + text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), + callback_data="admin_action:users_management" + ) + builder.button( + text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main" + ) + + builder.adjust(2, 2, 2, 2) + return builder + + +async def format_user_card(user: User, session: AsyncSession, + subscription_service: SubscriptionService, + i18n_instance, lang: str) -> str: + """Format user information as a detailed card""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + # Basic user info + card_parts = [] + card_parts.append(f"👤 {_('admin_user_card_title', default='Карточка пользователя')}\n") + + # User details + user_name = user.first_name or "N/A" + username_display = f"@{user.username}" if user.username else "N/A" + registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else "N/A" + + card_parts.append(f"🆔 ID: {hcode(str(user.user_id))}") + card_parts.append(f"👤 Имя: {hcode(user_name)}") + card_parts.append(f"📱 Username: {hcode(username_display)}") + card_parts.append(f"🌍 Язык: {hcode(user.language_code or 'N/A')}") + card_parts.append(f"📅 Регистрация: {hcode(registration_date)}") + + # Ban status + ban_status = "🚫 Заблокирован" if user.is_banned else "✅ Активен" + card_parts.append(f"🛡 Статус: {ban_status}") + + # Referral info + if user.referred_by_id: + card_parts.append(f"🎁 Привлечен по реферальной программе от: {hcode(str(user.referred_by_id))}") + + # Panel info + if user.panel_user_uuid: + card_parts.append(f"🔗 Panel UUID: {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") + + card_parts.append("") # Empty line + + # Subscription info + try: + subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id) + if subscription_details: + card_parts.append(f"💳 {_('admin_user_subscription_info', default='Информация о подписке:')}") + + end_date = subscription_details.get('end_date') + if end_date: + end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date) + card_parts.append(f"⏰ Действует до: {hcode(end_date_str)}") + + status = subscription_details.get('status_from_panel', 'UNKNOWN') + card_parts.append(f"📊 Статус на панели: {hcode(status)}") + + traffic_limit = subscription_details.get('traffic_limit_bytes') + traffic_used = subscription_details.get('traffic_used_bytes') + if traffic_limit and traffic_used is not None: + traffic_limit_gb = traffic_limit / (1024**3) + traffic_used_gb = traffic_used / (1024**3) + card_parts.append(f"📊 Трафик: {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") + else: + card_parts.append(f"💳 Подписка: {hcode('Отсутствует')}") + except Exception as e: + logging.error(f"Error getting subscription details for user {user.user_id}: {e}") + card_parts.append(f"💳 Подписка: {hcode('Ошибка загрузки')}") + + # Statistics + try: + # Count user logs + logs_count = await message_log_dal.count_user_message_logs(session, user.user_id) + card_parts.append(f"📜 Всего действий: {hcode(str(logs_count))}") + + # Check if user had any subscriptions + had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id) + trial_status = "Использовал" if had_subscriptions else "Не использовал" + card_parts.append(f"🆓 Триал: {hcode(trial_status)}") + + except Exception as e: + logging.error(f"Error getting user statistics for {user.user_id}: {e}") + + return "\n".join(card_parts) + + +@router.message(AdminStates.waiting_for_user_search, F.text) +async def process_user_search_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + """Process user search input and display user card""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + input_text = message.text.strip() if message.text else "" + user_model: Optional[User] = None + + # Try to find user by ID or username if input_text.isdigit(): try: - return await user_dal.get_user(session, user_id=int(input_text)) + user_model = await user_dal.get_user_by_id(session, int(input_text)) except ValueError: - return None - if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): - return await user_dal.get_user(session, username=input_text[1:]) - if USERNAME_REGEX.match(input_text): - return await user_dal.get_user(session, username=input_text) - return None + pass + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_model = await user_dal.get_user_by_username(session, input_text[1:]) + elif USERNAME_REGEX.match(input_text): + user_model = await user_dal.get_user_by_username(session, input_text) - -async def ban_user_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error processing ban prompt.", show_alert=True) + if not user_model: + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - prompt_text = _("admin_ban_user_prompt") + # Store user ID in state for further operations + await state.update_data(target_user_id=user_model.user_id) + await state.clear() + + # Format and send user card try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) except Exception as e: - logging.warning( - f"Edit failed for ban_user_prompt: {e}. Sending new message.") - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - - await callback.answer() - await state.set_state(AdminStates.waiting_for_user_id_to_ban) + logging.error(f"Error displaying user card for {user_model.user_id}: {e}") + await message.answer(_( + "admin_user_card_error", + default="❌ Ошибка отображения карточки пользователя" + )) -@router.message(AdminStates.waiting_for_user_id_to_ban, F.text) -async def process_user_input_to_ban_handler(message: types.Message, - state: FSMContext, i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession): +@router.callback_query(F.data.startswith("user_action:")) +async def user_action_handler(callback: types.CallbackQuery, state: FSMContext, + settings: Settings, i18n_data: dict, bot: Bot, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + session: AsyncSession): + """Handle user management actions""" + try: + parts = callback.data.split(":") + action = parts[1] + user_id = int(parts[2]) + except (IndexError, ValueError): + await callback.answer("Invalid action format.", show_alert=True) + return + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: - await message.reply("Language service error.") + await callback.answer("Language service error.", show_alert=True) return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - input_text = message.text.strip() if message.text else "" - user_to_ban_model = await _get_user_model_by_input(session, input_text) - - if not user_to_ban_model: - await message.answer( - _("admin_user_not_found_in_bot_db", user_id=input_text)) - await state.clear() + # Get user from database + user = await user_dal.get_user_by_id(session, user_id) + if not user: + await callback.answer(_( + "admin_user_not_found_action", + default="Пользователь не найден" + ), show_alert=True) return - user_id_to_ban = user_to_ban_model.user_id - user_display_for_msg = user_to_ban_model.username or str(user_id_to_ban) - - if message.from_user and (user_id_to_ban == message.from_user.id - or user_id_to_ban in settings.ADMIN_IDS): - await message.answer(_("admin_cannot_ban_self_or_admin")) - await state.clear() - return - - if user_to_ban_model.is_banned: - await message.answer( - _("admin_user_already_banned", - user_id_or_username=user_display_for_msg)) - await state.clear() - return - - ban_success_local = await user_dal.set_user_ban_status( - session, user_id_to_ban, True) - - if ban_success_local: - - panel_ban_message_part = "" - if user_to_ban_model.panel_user_uuid: - panel_ban_api_success = await panel_service.update_user_status_on_panel( - user_to_ban_model.panel_user_uuid, enable=False) - if panel_ban_api_success: - panel_ban_message_part = _("admin_panel_ban_success_part") - else: - panel_ban_message_part = _("admin_panel_ban_fail_part") - - await session.commit() - await message.answer(_("admin_user_banned_success_combined", - user_id_or_username=user_display_for_msg, - panel_status_part=panel_ban_message_part), - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n)) + if action == "reset_trial": + await handle_reset_trial(callback, user, subscription_service, session, i18n, current_lang) + elif action == "add_subscription": + await handle_add_subscription_prompt(callback, state, user, i18n, current_lang) + elif action == "toggle_ban": + await handle_toggle_ban(callback, user, panel_service, session, i18n, current_lang) + elif action == "send_message": + await handle_send_message_prompt(callback, state, user, i18n, current_lang) + elif action == "view_logs": + await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) + elif action == "refresh": + await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang) else: - await session.rollback() - await message.answer(_("admin_user_ban_failed_local_db_error")) - - await state.clear() + await callback.answer(_("admin_unknown_action"), show_alert=True) -async def unban_user_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error processing unban prompt.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - prompt_text = _("admin_unban_user_prompt") +async def handle_reset_trial(callback: types.CallbackQuery, user: User, + subscription_service: SubscriptionService, + session: AsyncSession, i18n_instance, lang: str): + """Reset user's trial eligibility""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + # Deactivate all user subscriptions + await subscription_dal.deactivate_all_user_subscriptions(session, user.user_id) + await session.commit() + + await callback.answer(_( + "admin_user_trial_reset_success", + default="✅ Триал сброшен! Пользователь может активировать триал заново." + ), show_alert=True) + + # Refresh user card + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) + except Exception as e: - logging.warning( - f"Edit failed for unban_user_prompt: {e}. Sending new message.") - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - - await callback.answer() - await state.set_state(AdminStates.waiting_for_user_id_to_unban) - - -@router.message(AdminStates.waiting_for_user_id_to_unban, F.text) -async def process_user_input_to_unban_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - input_text = message.text.strip() if message.text else "" - user_to_unban_model = await _get_user_model_by_input(session, input_text) - - if not user_to_unban_model: - await message.answer( - _("admin_user_not_found_in_bot_db", user_id=input_text)) - await state.clear() - return - - user_id_to_unban = user_to_unban_model.user_id - user_display_for_msg = user_to_unban_model.username or str( - user_id_to_unban) - - if not user_to_unban_model.is_banned: - await message.answer( - _("admin_user_not_banned", - user_id_or_username=user_display_for_msg)) - await state.clear() - return - - unban_success_local = await user_dal.set_user_ban_status( - session, user_id_to_unban, False) - - if unban_success_local: - panel_unban_message_part = "" - if user_to_unban_model.panel_user_uuid: - panel_unban_api_success = await panel_service.update_user_status_on_panel( - user_to_unban_model.panel_user_uuid, enable=True) - if panel_unban_api_success: - panel_unban_message_part = _("admin_panel_unban_success_part") - else: - panel_unban_message_part = _("admin_panel_unban_fail_part") - - await session.commit() - await message.answer(_("admin_user_unbanned_success_combined", - user_id_or_username=user_display_for_msg, - panel_status_part=panel_unban_message_part), - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n)) - else: + logging.error(f"Error resetting trial for user {user.user_id}: {e}") await session.rollback() - await message.answer(_("admin_user_unban_failed_local_db_error")) - - await state.clear() + await callback.answer(_( + "admin_user_trial_reset_error", + default="❌ Ошибка сброса триала" + ), show_alert=True) -async def view_banned_users_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, session: AsyncSession): - await state.clear() - current_page_idx = 0 - if ":" in callback.data and callback.data.count(":") == 2: - try: - current_page_idx = int(callback.data.split(":")[-1]) - except ValueError: - current_page_idx = 0 - - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying banned users.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - page_size = settings.LOGS_PAGE_SIZE - offset = current_page_idx * page_size - - banned_user_models, total_banned_count = await user_dal.get_banned_users_paginated( - session, limit=page_size, offset=offset) - - if total_banned_count == 0: - await callback.message.edit_text( - _("admin_no_banned_users"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - else: - total_pages = math.ceil(total_banned_count / - page_size) if page_size > 0 else 1 - await callback.message.edit_text( - text=_("admin_banned_list_title", - current_page=current_page_idx + 1, - total_pages=max(1, total_pages)), - reply_markup=get_banned_users_keyboard(banned_user_models, - current_page_idx, - total_banned_count, i18n, - current_lang, settings)) - await callback.answer() - - -async def _show_user_card_actual(target_message: types.Message, - user_id_to_show: int, - banned_list_page_to_return: int, - i18n_data: dict, settings: Settings, - panel_service: PanelApiService, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: return - - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - user_model = await user_dal.get_user_by_id(session, user_id_to_show) - if not user_model: - await target_message.edit_text( - _("admin_user_not_found_in_bot_db", user_id=user_id_to_show), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - return - - user_display_name = user_model.first_name or (f"@{user_model.username}" - if user_model.username else - f"ID: {user_id_to_show}") - - sub_end_date_str = await subscription_dal.get_user_active_subscription_end_date_str( - session, user_id_to_show) or _("user_card_sub_na") - - reg_date_display = user_model.registration_date.strftime( - '%Y-%m-%d %H:%M') if user_model.registration_date else "N/A" - - card_text = _("user_card_info", - user_id=user_model.user_id, - username=user_model.username or "N/A", - first_name=user_model.first_name or "", - last_name=user_model.last_name or "", - language_code=user_model.language_code or "N/A", - panel_user_uuid=user_model.panel_user_uuid or "N/A", - ban_status=_("user_card_banned") - if user_model.is_banned else _("user_card_active"), - reg_date=reg_date_display, - sub_end_date=sub_end_date_str) - await target_message.edit_text( - text= - f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}", - reply_markup=get_user_card_keyboard(user_id_to_show, - bool(user_model.is_banned), i18n, - current_lang, - banned_list_page_to_return), - parse_mode="HTML") - - -@router.callback_query(F.data.startswith("admin_user_card:")) -async def show_user_card_handler(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession, - force_user_id: Optional[int] = None, - force_page: Optional[int] = None): - await state.clear() - - user_id_to_show = 0 - banned_list_page_to_return = 0 - - if force_user_id is not None and force_page is not None: - user_id_to_show = force_user_id - banned_list_page_to_return = force_page - else: - try: - parts = callback.data.split(":") - user_id_to_show = int(parts[1]) - banned_list_page_to_return = int(parts[2]) if len(parts) > 2 else 0 - except (IndexError, ValueError): - await callback.answer("Invalid user card data.", show_alert=True) - return - - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying user card.", show_alert=True) - return - - await _show_user_card_actual( - target_message=callback.message, - user_id_to_show=user_id_to_show, - banned_list_page_to_return=banned_list_page_to_return, - i18n_data=i18n_data, - settings=settings, - panel_service=panel_service, - session=session) - if force_user_id is None: - await callback.answer() - - -async def _confirm_action_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession, action_type: str): +async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: FSMContext, + user: User, i18n_instance, lang: str): + """Prompt admin to enter subscription days to add""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_subscription_days_to_add) + + prompt_text = _( + "admin_user_add_subscription_prompt", + default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:", + user_id=user.user_id + ) + try: - - _, user_id_str, page_str = callback.data.split(":") - user_id = int(user_id_str) - banned_list_page = int(page_str) - except (ValueError, IndexError): - await callback.answer("Invalid confirmation data.", show_alert=True) - return - - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error processing confirmation.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - user_model = await user_dal.get_user_by_id(session, user_id) - user_display = (user_model.first_name or - (f"@{user_model.username}" if user_model.username else - f"ID {user_id}")) if user_model else f"ID {user_id}" - - prompt_key = f"admin_confirm_{action_type}_prompt" - yes_callback = f"admin_{action_type}_do:{user_id}:{banned_list_page}" - no_callback = f"admin_user_card:{user_id}:{banned_list_page}" - - await callback.message.edit_text( - text=_("admin_confirm_action_title", - action_text=_(f"{action_type}_verb_l")) + "\n\n" + - _(prompt_key, user_display=user_display, user_id=user_id), - reply_markup=get_confirmation_keyboard(yes_callback_data=yes_callback, - no_callback_data=no_callback, - i18n_instance=i18n, - lang=current_lang), - parse_mode="HTML") - await callback.answer() - - -@router.callback_query(F.data.startswith("admin_ban_confirm:")) -async def confirm_ban_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - await _confirm_action_handler(callback, i18n_data, settings, session, - "ban") - - -@router.callback_query(F.data.startswith("admin_unban_confirm:")) -async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - await _confirm_action_handler(callback, i18n_data, settings, session, - "unban") - - -async def _do_ban_unban_action_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - panel_service: PanelApiService, - session: AsyncSession, - state: FSMContext, action_type: str): - try: - - _, user_id_str, page_str = callback.data.split(":") - user_id_target = int(user_id_str) - banned_list_page = int(page_str) - except (ValueError, IndexError): - await callback.answer("Invalid action data.", show_alert=True) - return - - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error processing action.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - user_model = await user_dal.get_user_by_id(session, user_id_target) - if not user_model: - await callback.answer(_("admin_user_not_found_in_bot_db", - user_id=user_id_target), - show_alert=True) - return - - user_display_name = user_model.first_name or (f"@{user_model.username}" - if user_model.username else - f"ID {user_id_target}") - - is_banning = action_type == "ban" - - if is_banning: - if user_model.is_banned: - await callback.answer(_("admin_user_already_banned", - user_id_or_username=user_display_name), - show_alert=True) - return - if user_id_target == callback.from_user.id or user_id_target in settings.ADMIN_IDS: - await callback.answer(_("admin_cannot_ban_self_or_admin"), - show_alert=True) - return - else: - if not user_model.is_banned: - await callback.answer(_("admin_user_not_banned", - user_id_or_username=user_display_name), - show_alert=True) - return - - action_success_local = await user_dal.set_user_ban_status( - session, user_id_target, is_banning) - - if action_success_local: - panel_action_message = "" - if user_model.panel_user_uuid: - panel_api_success = await panel_service.update_user_status_on_panel( - user_model.panel_user_uuid, enable=not is_banning) - if not panel_api_success: - panel_action_message = _("admin_panel_status_update_fail_part") - logging.warning( - f"Panel status update failed for {action_type} of user {user_id_target} (panel: {user_model.panel_user_uuid})" - ) - - await session.commit() - alert_message_key = f"admin_user_{action_type}ned_from_card_alert" - await callback.answer(_(alert_message_key, - user_display=user_display_name, - user_id=user_id_target) + " " + - panel_action_message, - show_alert=False) - else: - await session.rollback() - await callback.answer(_(f"admin_user_{action_type}_failed_db_error"), - show_alert=True) - return - - await show_user_card_handler(callback, - state, - i18n_data, - settings, - panel_service, - session, - force_user_id=user_id_target, - force_page=banned_list_page) - - -@router.callback_query(F.data.startswith("admin_ban_do:")) -async def do_ban_user_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession, state: FSMContext): - await _do_ban_unban_action_handler(callback, i18n_data, settings, - panel_service, session, state, "ban") - - -@router.callback_query(F.data.startswith("admin_unban_do:")) -async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, - panel_service: PanelApiService, - session: AsyncSession, state: FSMContext): - await _do_ban_unban_action_handler(callback, i18n_data, settings, - panel_service, session, state, "unban") - - -@router.callback_query(F.data == "admin_action:main", - AdminStates.waiting_for_user_id_to_ban) -@router.callback_query(F.data == "admin_action:main", - AdminStates.waiting_for_user_id_to_unban) -async def cancel_user_management_input_state( - callback: types.CallbackQuery, - state: FSMContext, - settings: Settings, - i18n_data: dict, - bot: Bot, -): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error cancelling.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - await callback.message.edit_text(_("admin_action_cancelled_default"), - reply_markup=get_admin_panel_keyboard( - i18n, current_lang, settings)) + await callback.message.edit_text(prompt_text) except Exception: - await callback.message.answer(_("admin_action_cancelled_default"), - reply_markup=get_admin_panel_keyboard( - i18n, current_lang, settings)) - await callback.answer(_("admin_action_cancelled_default_alert")) + await callback.message.answer(prompt_text) + + await callback.answer() + + +async def handle_toggle_ban(callback: types.CallbackQuery, user: User, + panel_service: PanelApiService, session: AsyncSession, + i18n_instance, lang: str): + """Toggle user ban status""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + new_ban_status = not user.is_banned + + # Update in database + await user_dal.update_user(session, user.user_id, {"is_banned": new_ban_status}) + + # Update on panel if user has panel UUID + if user.panel_user_uuid: + panel_status = "DISABLED" if new_ban_status else "ACTIVE" + await panel_service.update_user_status_on_panel(user.panel_user_uuid, not new_ban_status) + + await session.commit() + + status_text = "заблокирован" if new_ban_status else "разблокирован" + await callback.answer(_( + "admin_user_ban_toggle_success", + default="✅ Пользователь {status}", + status=status_text + ), show_alert=True) + + # Refresh user card with updated ban status + user.is_banned = new_ban_status # Update local object + await handle_refresh_user_card(callback, user, None, session, i18n_instance, lang) + + except Exception as e: + logging.error(f"Error toggling ban for user {user.user_id}: {e}") + await session.rollback() + await callback.answer(_( + "admin_user_ban_toggle_error", + default="❌ Ошибка изменения статуса блокировки" + ), show_alert=True) + + +async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMContext, + user: User, i18n_instance, lang: str): + """Prompt admin to enter message to send to user""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + await state.update_data(target_user_id=user.user_id) + await state.set_state(AdminStates.waiting_for_direct_message_to_user) + + prompt_text = _( + "admin_user_send_message_prompt", + default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", + user_id=user.user_id + ) + + try: + await callback.message.edit_text(prompt_text) + except Exception: + await callback.message.answer(prompt_text) + + await callback.answer() + + +async def handle_view_user_logs(callback: types.CallbackQuery, user: User, + session: AsyncSession, settings: Settings, + i18n_instance, lang: str): + """Show recent user logs""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + # Get recent logs for user + logs = await message_log_dal.get_user_message_logs(session, user.user_id, limit=10, offset=0) + + if not logs: + await callback.answer(_( + "admin_user_no_logs", + default="📜 У пользователя нет действий" + ), show_alert=True) + return + + logs_text_parts = [f"📜 Последние действия пользователя {user.user_id}:\n"] + + for log in logs: + timestamp = log.timestamp.strftime('%Y-%m-%d %H:%M') if log.timestamp else 'N/A' + event_type = log.event_type or 'N/A' + content_preview = (log.content or '')[:50] + ('...' if len(log.content or '') > 50 else '') + + logs_text_parts.append( + f"🕐 {hcode(timestamp)} - {hcode(event_type)}\n" + f" {content_preview}" + ) + + logs_text = "\n\n".join(logs_text_parts) + + # Create inline keyboard for full logs + builder = InlineKeyboardBuilder() + builder.button( + text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"), + callback_data=f"admin_logs:view_user:{user.user_id}:0" + ) + builder.button( + text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"), + callback_data=f"user_action:refresh:{user.user_id}" + ) + builder.adjust(1) + + try: + await callback.message.edit_text( + logs_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + logs_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logging.error(f"Error viewing logs for user {user.user_id}: {e}") + await callback.answer(_( + "admin_user_logs_error", + default="❌ Ошибка загрузки действий пользователя" + ), show_alert=True) + + +async def handle_refresh_user_card(callback: types.CallbackQuery, user: User, + subscription_service: SubscriptionService, + session: AsyncSession, i18n_instance, lang: str): + """Refresh user card with latest information""" + try: + # Reload user from database + fresh_user = await user_dal.get_user_by_id(session, user.user_id) + if not fresh_user: + await callback.answer("User not found", show_alert=True) + return + + user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang) + keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang) + + try: + await callback.message.edit_text( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + + await callback.answer() + + except Exception as e: + logging.error(f"Error refreshing user card for {user.user_id}: {e}") + await callback.answer("Error refreshing user card", show_alert=True) + + +# Message handlers for state-based inputs + +@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text) +async def process_subscription_days_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + subscription_service: SubscriptionService, + session: AsyncSession): + """Process subscription days input""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer("Error: target user not found in state") + await state.clear() + return + + try: + days_to_add = int(message.text.strip()) + if days_to_add <= 0 or days_to_add > 3650: # Max 10 years + raise ValueError("Invalid days count") + except ValueError: + await message.answer(_( + "admin_user_invalid_days", + default="❌ Неверное количество дней. Введите число от 1 до 3650." + )) + return + + try: + # Extend subscription + result = await subscription_service.extend_active_subscription_days( + session, target_user_id, days_to_add, "admin_manual_extension" + ) + + if result: + await session.commit() + await message.answer(_( + "admin_user_subscription_added_success", + default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}", + days=days_to_add, + user_id=target_user_id + )) + + # Show updated user card + user = await user_dal.get_user_by_id(session, target_user_id) + if user: + user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + else: + await session.rollback() + await message.answer(_( + "admin_user_subscription_added_error", + default="❌ Ошибка добавления дней подписки" + )) + + except Exception as e: + logging.error(f"Error adding subscription days for user {target_user_id}: {e}") + await session.rollback() + await message.answer(_( + "admin_user_subscription_added_error", + default="❌ Ошибка добавления дней подписки" + )) + await state.clear() + + +@router.message(AdminStates.waiting_for_direct_message_to_user, F.text) +async def process_direct_message_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + bot: Bot, session: AsyncSession): + """Process direct message to user""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + target_user_id = data.get("target_user_id") + if not target_user_id: + await message.answer("Error: target user not found in state") + await state.clear() + return + + message_text = message.text.strip() + if len(message_text) > 4000: + await message.answer(_( + "admin_user_message_too_long", + default="❌ Сообщение слишком длинное (максимум 4000 символов)" + )) + return + + try: + # Get target user + target_user = await user_dal.get_user_by_id(session, target_user_id) + if not target_user: + await message.answer("Target user not found") + await state.clear() + return + + # Prepare message with admin signature + admin_signature = _( + "admin_direct_message_signature", + default="\n\n---\n💬 Сообщение от администратора" + ) + full_message = message_text + admin_signature + + # Send message to user + await bot.send_message(target_user_id, full_message) + + # Confirm to admin + await message.answer(_( + "admin_user_message_sent_success", + default="✅ Сообщение отправлено пользователю {user_id}", + user_id=target_user_id + )) + + # Show user card again + user_card_text = await format_user_card(target_user, session, None, i18n, current_lang) + keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + + except Exception as e: + logging.error(f"Error sending direct message to user {target_user_id}: {e}") + await message.answer(_( + "admin_user_message_sent_error", + default="❌ Ошибка отправки сообщения" + )) + + await state.clear() \ No newline at end of file diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py new file mode 100644 index 0000000..693ea7a --- /dev/null +++ b/bot/handlers/inline_mode.py @@ -0,0 +1,351 @@ +import logging +from aiogram import Router, types, Bot +from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import user_dal, payment_dal +from bot.services.referral_service import ReferralService +from bot.middlewares.i18n import JsonI18n + +router = Router(name="inline_mode_router") + + +@router.inline_query() +async def inline_query_handler(inline_query: InlineQuery, + settings: Settings, + i18n_data: dict, + referral_service: ReferralService, + bot: Bot, + session: AsyncSession): + """Handle inline queries for referral links and admin statistics""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + user_id = inline_query.from_user.id + query = inline_query.query.lower().strip() + + results: List[InlineQueryResultArticle] = [] + + # Check if user is admin + is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False + + try: + # For all users: referral functionality + if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query: + referral_result = await create_referral_result( + inline_query, bot, referral_service, i18n, current_lang + ) + if referral_result: + results.append(referral_result) + + # For admins: statistics + if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query): + stats_results = await create_admin_stats_results( + session, i18n, current_lang + ) + results.extend(stats_results) + + # Show help if no specific query + if not query: + help_result = await create_help_result(i18n, current_lang, is_admin) + results.append(help_result) + + # Limit results to 50 (Telegram limit) + results = results[:50] + + await inline_query.answer( + results=results, + cache_time=30, # Cache for 30 seconds + is_personal=True # Results are personalized + ) + + except Exception as e: + logging.error(f"Error handling inline query from user {user_id}: {e}") + # Send empty results in case of error + await inline_query.answer(results=[], cache_time=10) + + +async def create_referral_result(inline_query: InlineQuery, bot: Bot, + referral_service: ReferralService, + i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: + """Create referral link result for inline query""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + bot_info = await bot.get_me() + bot_username = bot_info.username + if not bot_username: + return None + + user_id = inline_query.from_user.id + referral_link = referral_service.generate_referral_link(bot_username, user_id) + + # Create message content + message_text = _( + "inline_referral_message", + default="🚀 Привет! Попробуй этот крутой VPN сервис!\n\n" + "✨ Быстрый и надежный\n" + "🔒 Полная анонимность\n" + "🌍 Серверы по всему миру\n" + "💎 Бесплатный пробный период\n\n" + "Переходи по ссылке: {referral_link}", + referral_link=referral_link + ) + + return InlineQueryResultArticle( + id="referral_link", + title=_( + "inline_referral_title", + default="🎁 Пригласить друга" + ), + description=_( + "inline_referral_description", + default="Поделиться реферальной ссылкой для получения бонусов" + ), + input_message_content=InputTextMessageContent( + message_text=message_text, + disable_web_page_preview=True + ), + thumbnail_url="https://cdn-icons-png.flaticon.com/512/1077/1077114.png" + ) + + except Exception as e: + logging.error(f"Error creating referral result: {e}") + return None + + +async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str) -> List[InlineQueryResultArticle]: + """Create admin statistics results for inline query""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + results = [] + + try: + # Quick user stats + user_stats_result = await create_user_stats_result(session, i18n_instance, lang) + if user_stats_result: + results.append(user_stats_result) + + # Quick financial stats + financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang) + if financial_stats_result: + results.append(financial_stats_result) + + # Quick system stats + system_stats_result = await create_system_stats_result(session, i18n_instance, lang) + if system_stats_result: + results.append(system_stats_result) + + except Exception as e: + logging.error(f"Error creating admin stats results: {e}") + + return results + + +async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: + """Create user statistics result""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from db.dal.user_dal import get_enhanced_user_statistics + user_stats = await get_enhanced_user_statistics(session) + + stats_text = _( + "inline_user_stats_message", + default="👥 Статистика пользователей\n\n" + "📊 Всего: {total}\n" + "📈 Активных сегодня: {active_today}\n" + "💳 С платной подпиской: {paid}\n" + "🆓 На пробном периоде: {trial}\n" + "😴 Неактивных: {inactive}\n" + "🚫 Заблокированных: {banned}\n" + "🎁 По реферальной программе: {referral}", + total=user_stats['total_users'], + active_today=user_stats['active_today'], + paid=user_stats['paid_subscriptions'], + trial=user_stats['trial_users'], + inactive=user_stats['inactive_users'], + banned=user_stats['banned_users'], + referral=user_stats['referral_users'] + ) + + return InlineQueryResultArticle( + id="admin_user_stats", + title=_( + "inline_admin_user_stats_title", + default="👥 Статистика пользователей" + ), + description=_( + "inline_admin_user_stats_desc", + default=f"Всего: {user_stats['total_users']}, Активных: {user_stats['paid_subscriptions']}" + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url="https://cdn-icons-png.flaticon.com/512/681/681494.png" + ) + + except Exception as e: + logging.error(f"Error creating user stats result: {e}") + return None + + +async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: + """Create financial statistics result""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from db.dal.payment_dal import get_financial_statistics + financial_stats = await get_financial_statistics(session) + + stats_text = _( + "inline_financial_stats_message", + default="💰 Финансовая статистика\n\n" + "📅 За сегодня: {today:.2f} RUB\n" + " ({today_count} платежей)\n" + "📅 За неделю: {week:.2f} RUB\n" + "📅 За месяц: {month:.2f} RUB\n" + "🏆 За все время: {all_time:.2f} RUB", + today=financial_stats['today_revenue'], + today_count=financial_stats['today_payments_count'], + week=financial_stats['week_revenue'], + month=financial_stats['month_revenue'], + all_time=financial_stats['all_time_revenue'] + ) + + return InlineQueryResultArticle( + id="admin_financial_stats", + title=_( + "inline_admin_financial_stats_title", + default="💰 Финансовая статистика" + ), + description=_( + "inline_admin_financial_stats_desc", + default=f"Сегодня: {financial_stats['today_revenue']:.2f} RUB" + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url="https://cdn-icons-png.flaticon.com/512/2769/2769339.png" + ) + + except Exception as e: + logging.error(f"Error creating financial stats result: {e}") + return None + + +async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: + """Create system statistics result with online/offline/expired/limited info""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + try: + from datetime import datetime, timezone + from sqlalchemy import select, func, and_ + from db.models import User, Subscription + + now = datetime.now(timezone.utc) + + # Count active subscriptions (online) + active_subs_stmt = select(func.count(Subscription.subscription_id)).where( + and_( + Subscription.is_active == True, + Subscription.end_date > now + ) + ) + active_subs = (await session.execute(active_subs_stmt)).scalar() or 0 + + # Count expired subscriptions + expired_subs_stmt = select(func.count(Subscription.subscription_id)).where( + and_( + Subscription.is_active == True, + Subscription.end_date <= now + ) + ) + expired_subs = (await session.execute(expired_subs_stmt)).scalar() or 0 + + # Count total users (approximation for "total") + total_users_stmt = select(func.count(User.user_id)) + total_users = (await session.execute(total_users_stmt)).scalar() or 0 + + # Offline = users without active subscriptions + offline_users = total_users - active_subs + + stats_text = _( + "inline_system_stats_message", + default="🖥 Системная статистика\n\n" + "🟢 Онлайн: {online}\n" + "🔴 Офлайн: {offline}\n" + "⏰ Истекшие: {expired}\n" + "👥 Всего пользователей: {total}", + online=active_subs, + offline=max(0, offline_users), + expired=expired_subs, + total=total_users + ) + + return InlineQueryResultArticle( + id="admin_system_stats", + title=_( + "inline_admin_system_stats_title", + default="🖥 Системная статистика" + ), + description=_( + "inline_admin_system_stats_desc", + default=f"Онлайн: {active_subs}, Офлайн: {max(0, offline_users)}" + ), + input_message_content=InputTextMessageContent( + message_text=stats_text, + parse_mode="HTML" + ), + thumbnail_url="https://cdn-icons-png.flaticon.com/512/2920/2920277.png" + ) + + except Exception as e: + logging.error(f"Error creating system stats result: {e}") + return None + + +async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> InlineQueryResultArticle: + """Create help result explaining inline mode features""" + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + + if is_admin: + help_text = _( + "inline_admin_help_message", + default="🤖 Inline режим бота\n\n" + "📱 Доступные команды:\n\n" + "🎁 реф/ref - поделиться реферальной ссылкой\n" + "👥 стат/stat - статистика пользователей\n" + "💰 финансы - финансовая статистика\n" + "🖥 система - системная статистика\n\n" + "💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!" + ) + title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)") + description = _("inline_admin_help_desc", default="Доступны команды: реф, стат, финансы, система") + else: + help_text = _( + "inline_user_help_message", + default="🤖 Inline режим бота\n\n" + "📱 Доступные команды:\n\n" + "🎁 реф/ref - поделиться реферальной ссылкой\n\n" + "💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!" + ) + title = _("inline_user_help_title", default="🤖 Inline помощь") + description = _("inline_user_help_desc", default="Доступна команда: реф (реферальная ссылка)") + + return InlineQueryResultArticle( + id="help", + title=title, + description=description, + input_message_content=InputTextMessageContent( + message_text=help_text, + parse_mode="HTML" + ), + thumbnail_url="https://cdn-icons-png.flaticon.com/512/906/906794.png" + ) \ No newline at end of file diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 76e7c2a..bc54a9c 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -20,7 +20,7 @@ from bot.services.panel_api_service import PanelApiService from bot.services.yookassa_service import YooKassaService from bot.middlewares.i18n import JsonI18n from config.settings import Settings -from bot.services.notification_service import notify_admin_new_payment +from bot.services.notification_service import notify_admin_new_payment, NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard payment_processing_lock = asyncio.Lock() @@ -195,6 +195,22 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, f"Failed to send payment details message to user {user_id}: {e_notify}" ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=payment_value, + currency=settings.DEFAULT_CURRENCY_SYMBOL, + months=subscription_months, + payment_provider="yookassa", # This is specifically for YooKassa webhook + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send payment notification: {e}") + + # Legacy notification for backwards compatibility await notify_admin_new_payment( bot, settings, diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index ea06161..be6b418 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -87,7 +87,8 @@ async def referral_command_handler(event: Union[types.Message, referral_link=referral_link, bonus_details=bonus_details_str) - reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n) + from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard + reply_markup_val = get_referral_link_keyboard(current_lang, i18n) if isinstance(event, types.Message): await event.answer(text, @@ -106,3 +107,37 @@ async def referral_command_handler(event: Union[types.Message, reply_markup=reply_markup_val, disable_web_page_preview=True) await event.answer() + + +@router.callback_query(F.data.startswith("referral_action:")) +async def referral_action_handler(callback: types.CallbackQuery, settings: Settings, + i18n_data: dict, referral_service: ReferralService, + bot: Bot, session: AsyncSession): + action = callback.data.split(":")[1] + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n = i18n_data.get("i18n_instance") + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + if action == "share_message": + try: + bot_info = await bot.get_me() + bot_username = bot_info.username + if not bot_username: + await callback.answer("Ошибка получения имени бота", show_alert=True) + return + + inviter_user_id = callback.from_user.id + referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id) + + friend_message = _("referral_friend_message", referral_link=referral_link) + + await callback.message.answer( + friend_message, + disable_web_page_preview=True + ) + + except Exception as e: + logging.error(f"Error in referral share message: {e}") + await callback.answer("Произошла ошибка", show_alert=True) + + await callback.answer() diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index f1db7ba..996e7de 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -129,6 +129,8 @@ async def start_command_handler(message: types.Message, user_id = user.id referred_by_user_id: Optional[int] = None + promo_code_to_apply: Optional[str] = None + if command and command.args: arg_payload = command.args if arg_payload.startswith("ref_"): @@ -142,6 +144,14 @@ async def start_command_handler(message: types.Message, logging.warning( f"Could not parse referral from /start args '{arg_payload}': {e}" ) + elif arg_payload.startswith("promo_"): + try: + promo_code_to_apply = arg_payload.split("_")[1] + logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") + except (IndexError, ValueError) as e: + logging.warning( + f"Could not parse promo code from /start args '{arg_payload}': {e}" + ) db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: @@ -160,6 +170,19 @@ async def start_command_handler(message: types.Message, logging.info( f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." ) + + # Send notification about new user registration + try: + from bot.services.notification_service import NotificationService + notification_service = NotificationService(message.bot, settings, i18n) + await notification_service.notify_new_user_registration( + user_id=user_id, + username=user.username, + first_name=user.first_name, + referred_by_id=referred_by_user_id + ) + except Exception as e: + logging.error(f"Failed to send new user notification: {e}") except Exception as e_create: logging.error( @@ -194,6 +217,53 @@ async def start_command_handler(message: types.Message, exc_info=True) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) + + # Auto-apply promo code if provided via start parameter + if promo_code_to_apply: + try: + from bot.services.promo_code_service import PromoCodeService + promo_code_service = PromoCodeService() + + success, result = await promo_code_service.apply_promo_code( + session, user_id, promo_code_to_apply, current_lang + ) + + if success: + await session.commit() + logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}") + + # Get updated subscription details + active = await subscription_service.get_active_subscription_details(session, user_id) + config_link = active.get("config_link") if active else None + config_link = config_link or _("config_link_not_available") + + from datetime import datetime + new_end_date = result if isinstance(result, datetime) else None + + promo_success_text = _( + "promo_code_applied_success_full", + end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), + config_link=config_link, + ) + + from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard + await message.answer( + promo_success_text, + reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link), + parse_mode="HTML" + ) + + # Don't show main menu if promo was successfully applied + return + else: + await session.rollback() + logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") + # Continue to show main menu if promo failed + + except Exception as e: + logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") + await session.rollback() + await send_main_menu(message, settings, i18n_data, diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index ad38106..d390086 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -59,21 +59,94 @@ async def request_trial_confirmation_handler( await callback.answer() return - traffic_gb_display = ( - str(settings.TRIAL_TRAFFIC_LIMIT_GB) - if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 - else _("traffic_unlimited") + # Directly activate trial without confirmation + activation_result = await subscription_service.activate_trial_subscription( + session, user_id ) - await callback.message.edit_text( - text=_( - "trial_confirm_prompt", - days=settings.TRIAL_DURATION_DAYS, - traffic_gb=traffic_gb_display, - ), - reply_markup=get_trial_confirmation_keyboard(current_lang, i18n), - ) - await callback.answer() + final_message_text_in_chat = "" + show_trial_button_after_action = False + + if activation_result and activation_result.get("activated"): + await callback.answer(_("trial_activated_alert"), show_alert=True) + + end_date_obj = activation_result.get("end_date") + config_link_for_trial = activation_result.get("subscription_url") or _( + "config_link_not_available" + ) + + traffic_gb_val = activation_result.get( + "traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB + ) + traffic_display = ( + f"{traffic_gb_val} GB" + if traffic_gb_val and traffic_gb_val > 0 + else _("traffic_unlimited") + ) + + final_message_text_in_chat = _( + "trial_activated_details_message", + days=activation_result.get("days", settings.TRIAL_DURATION_DAYS), + end_date=( + end_date_obj.strftime("%Y-%m-%d") + if isinstance(end_date_obj, datetime) + else "N/A" + ), + config_link=config_link_for_trial, + traffic_gb=traffic_display, + ) + + # Send notification to admin about new trial + await notify_admin_new_trial( + callback.bot, + settings, + i18n, + user_id, + end_date_obj, + ) + else: + message_key_from_service = ( + activation_result.get("message_key", "trial_activation_failed") + if activation_result + else "trial_activation_failed" + ) + final_message_text_in_chat = _(message_key_from_service) + await callback.answer(final_message_text_in_chat, show_alert=True) + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_had_any_subscription( + session, user_id + ) + ): + show_trial_button_after_action = True + + try: + await callback.message.edit_text( + final_message_text_in_chat, + parse_mode="HTML", + reply_markup=get_main_menu_inline_keyboard( + current_lang, i18n, settings, show_trial_button_after_action + ), + disable_web_page_preview=True, + ) + except Exception as e_edit: + logging.warning( + f"Could not edit trial result message: {e_edit}. Sending new one." + ) + + if ( + callback.message + and hasattr(callback.message, "chat") + and callback.message.chat + ): + await callback.message.chat.send_message( + final_message_text_in_chat, + parse_mode="HTML", + reply_markup=get_main_menu_inline_keyboard( + current_lang, i18n, settings, show_trial_button_after_action + ), + disable_web_page_preview=True, + ) @router.callback_query(F.data == "trial_action:confirm_activate") diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 6dae708..15e7c32 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -12,27 +12,105 @@ def get_admin_panel_keyboard(i18n_instance, lang: str, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + + # Статистика и мониторинг + builder.button(text=_(key="admin_stats_and_monitoring_section"), + callback_data="admin_section:stats_monitoring") + + # Управление пользователями + builder.button(text=_(key="admin_user_management_section"), + callback_data="admin_section:user_management") + + # Промокоды и маркетинг + builder.button(text=_(key="admin_promo_marketing_section"), + callback_data="admin_section:promo_marketing") + + # Системные функции + builder.button(text=_(key="admin_system_functions_section"), + callback_data="admin_section:system_functions") + + builder.adjust(1) + return builder.as_markup() + + +def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_stats_button"), callback_data="admin_action:stats") - builder.button(text=_(key="admin_broadcast_button"), - callback_data="admin_action:broadcast") - builder.button(text=_(key="admin_create_promo_button"), - callback_data="admin_action:create_promo") - builder.button(text=_(key="admin_manage_promos_button"), - callback_data="admin_action:manage_promos") - builder.button(text=_(key="admin_view_promos_button"), - callback_data="admin_action:view_promos") + builder.button(text=_(key="admin_view_logs_menu_button"), + callback_data="admin_action:view_logs_menu") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) + return builder.as_markup() + + +def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_users_management_button"), + callback_data="admin_action:users_management") + builder.button(text=_(key="admin_ban_management_section"), + callback_data="admin_section:ban_management") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) + return builder.as_markup() + + +def get_ban_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.button(text=_(key="admin_ban_user_button"), callback_data="admin_action:ban_user_prompt") builder.button(text=_(key="admin_unban_user_button"), callback_data="admin_action:unban_user_prompt") builder.button(text=_(key="admin_view_banned_users_button"), callback_data="admin_action:view_banned:0") - builder.button(text=_(key="admin_view_logs_menu_button"), - callback_data="admin_action:view_logs_menu") + + builder.button(text=_(key="back_to_user_management_button"), + callback_data="admin_section:user_management") + builder.adjust(2, 1, 1) + return builder.as_markup() + + +def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_create_promo_button"), + callback_data="admin_action:create_promo") + builder.button(text=_(key="admin_create_bulk_promo_button"), + callback_data="admin_action:create_bulk_promo") + builder.button(text=_(key="admin_manage_promos_button"), + callback_data="admin_action:manage_promos") + builder.button(text=_(key="admin_view_promos_button"), + callback_data="admin_action:view_promos") + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 2, 1) + return builder.as_markup() + + +def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + + builder.button(text=_(key="admin_broadcast_button"), + callback_data="admin_action:broadcast") builder.button(text=_(key="admin_sync_panel_button"), callback_data="admin_action:sync_panel") - builder.adjust(2, 2, 2, 2, 1) + + builder.button(text=_(key="back_to_admin_panel_button"), + callback_data="admin_action:main") + builder.adjust(2, 1) return builder.as_markup() @@ -43,9 +121,12 @@ def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: callback_data="admin_logs:view_all:0") builder.button(text=_(key="admin_view_user_logs_prompt_button"), callback_data="admin_logs:prompt_user") + builder.button(text=_(key="admin_export_logs_csv_button"), + callback_data="admin_logs:export_csv") builder.row( InlineKeyboardButton(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main")) + builder.adjust(2, 1, 1) return builder.as_markup() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 49337cd..6a0898f 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -158,8 +158,11 @@ def get_referral_link_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + builder.button(text=_(key="referral_share_message_button"), + callback_data="referral_action:share_message") builder.button(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main") + builder.adjust(1) return builder.as_markup() diff --git a/bot/main_bot.py b/bot/main_bot.py index 251cf93..1408c22 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -29,6 +29,7 @@ from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.handlers.user import user_router_aggregate from bot.handlers.admin import admin_router_aggregate +from bot.handlers import inline_mode from bot.filters.admin_filter import AdminFilter from bot.services.yookassa_service import YooKassaService @@ -78,6 +79,9 @@ class DBSessionMiddleware(BaseMiddleware): async def register_all_routers(dp: Dispatcher, settings: Settings): dp.include_router(user_router_aggregate) + + # Add inline mode router (available for all users) + dp.include_router(inline_mode.router) admin_main_router = Router(name="admin_main_filtered_router") admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 37cc534..a8166dc 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -3,13 +3,202 @@ import asyncio from aiogram import Bot from aiogram.utils.text_decorations import html_decoration as hd from datetime import datetime, timezone +from typing import Optional, Union, Dict, Any from config.settings import Settings - from sqlalchemy.orm import sessionmaker from bot.middlewares.i18n import JsonI18n +class NotificationService: + """Enhanced notification service for sending messages to admins and log channels""" + + def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None): + self.bot = bot + self.settings = settings + self.i18n = i18n + + async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None): + """Send message to configured log channel/group""" + if not self.settings.LOG_CHAT_ID: + return + + try: + # Use thread_id if provided, otherwise use from settings + final_thread_id = thread_id or self.settings.LOG_THREAD_ID + + kwargs = { + "chat_id": self.settings.LOG_CHAT_ID, + "text": message, + "parse_mode": "HTML", + "disable_web_page_preview": True + } + + # Add thread ID for supergroups if specified + if final_thread_id: + kwargs["message_thread_id"] = final_thread_id + + await self.bot.send_message(**kwargs) + + except Exception as e: + logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}") + + async def _send_to_admins(self, message: str): + """Send message to all admin users""" + if not self.settings.ADMIN_IDS: + return + + for admin_id in self.settings.ADMIN_IDS: + try: + await self.bot.send_message( + chat_id=admin_id, + text=message, + parse_mode="HTML", + disable_web_page_preview=True + ) + except Exception as e: + logging.error(f"Failed to send notification to admin {admin_id}: {e}") + + async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None, + first_name: Optional[str] = None, + referred_by_id: Optional[int] = None): + """Send notification about new user registration""" + if not self.settings.LOG_NEW_USERS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = first_name or f"ID {user_id}" + if username: + user_display += f" (@{username})" + + referral_text = "" + if referred_by_id: + referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id) + + message = _( + "log_new_user_registration", + default="👤 Новый пользователь\n\n" + "🆔 ID: {user_id}\n" + "👤 Имя: {user_display}{referral_text}\n" + "📅 Время: {timestamp}", + user_id=user_id, + user_display=user_display, + referral_text=referral_text, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_payment_received(self, user_id: int, amount: float, currency: str, + months: int, payment_provider: str, + username: Optional[str] = None): + """Send notification about successful payment""" + if not self.settings.LOG_PAYMENTS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + provider_emoji = { + "yookassa": "💳", + "cryptopay": "₿", + "stars": "⭐", + "tribute": "💎" + }.get(payment_provider.lower(), "💰") + + message = _( + "log_payment_received", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "📅 Период: {months} мес.\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + months=months, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int, + username: Optional[str] = None): + """Send notification about promo code activation""" + if not self.settings.LOG_PROMO_ACTIVATIONS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_promo_activation", + default="🎁 Активирован промокод\n\n" + "👤 Пользователь: {user_display}\n" + "🏷 Код: {promo_code}\n" + "🎯 Бонус: +{bonus_days} дн.\n" + "🕐 Время: {timestamp}", + user_display=user_display, + promo_code=promo_code, + bonus_days=bonus_days, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def notify_trial_activation(self, user_id: int, end_date: datetime, + username: Optional[str] = None): + """Send notification about trial activation""" + if not self.settings.LOG_TRIAL_ACTIVATIONS: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k + + user_display = f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_trial_activation", + default="🆓 Активирован триал\n\n" + "👤 Пользователь: {user_display}\n" + "⏰ Действует до: {end_date}\n" + "🕐 Время: {timestamp}", + user_display=user_display, + end_date=end_date.strftime("%Y-%m-%d %H:%M"), + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + + # Send to log channel + await self._send_to_log_channel(message) + + async def send_custom_notification(self, message: str, to_admins: bool = False, + to_log_channel: bool = True, thread_id: Optional[int] = None): + """Send custom notification message""" + if to_log_channel: + await self._send_to_log_channel(message, thread_id) + if to_admins: + await self._send_to_admins(message) + + +# Legacy functions for backward compatibility async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n, message_key: str, parse_mode: str | None = None, **kwargs) -> None: @@ -26,15 +215,9 @@ async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n, async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n, user_id: int, end_date: datetime) -> None: - end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date) - await notify_admins( - bot, - settings, - i18n, - "admin_new_trial_notification", - user_id=user_id, - end_date=end_date_str, - ) + """Send notification to admins about new trial activation (legacy)""" + notification_service = NotificationService(bot, settings, i18n) + await notification_service.notify_trial_activation(user_id, end_date) async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n, @@ -65,4 +248,4 @@ async def notify_admin_promo_activation(bot: Bot, settings: Settings, user_id=user_id, code=code, bonus_days=bonus_days, - ) + ) \ No newline at end of file diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index a9bee97..4338d81 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -11,7 +11,7 @@ from db.models import PromoCode, User from .subscription_service import SubscriptionService from bot.middlewares.i18n import JsonI18n -from .notification_service import notify_admin_promo_activation +from .notification_service import notify_admin_promo_activation, NotificationService class PromoCodeService: @@ -62,6 +62,20 @@ class PromoCodeService: session, promo_data.promo_code_id) if activation_recorded and promo_incremented: + # Send notification about promo activation + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_promo_activation( + user_id=user_id, + promo_code=code_input_upper, + bonus_days=bonus_days, + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send promo activation notification: {e}") + + # Legacy notification for backwards compatibility await notify_admin_promo_activation( self.bot, self.settings, diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 854bc6c..e7b4c63 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -7,7 +7,13 @@ class AdminStates(StatesGroup): confirming_broadcast = State() waiting_for_promo_details = State() waiting_for_promo_edit_details = State() + waiting_for_bulk_promo_details = State() waiting_for_user_id_to_ban = State() waiting_for_user_id_to_unban = State() waiting_for_user_id_for_logs = State() + + # User management states + waiting_for_user_search = State() + waiting_for_subscription_days_to_add = State() + waiting_for_direct_message_to_user = State() diff --git a/config/settings.py b/config/settings.py index e410eb5..ede723b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -288,6 +288,16 @@ class Settings(BaseSettings): if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None: bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses + + # Logging Configuration + LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") + LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)") + + # Notification types + LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") + LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments") + LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations") + LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations") model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index b530212..dce3514 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -155,3 +155,67 @@ async def update_provider_payment_and_status( f"Payment record with DB ID {payment_db_id} not found for provider update." ) return payment + + +async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: + """Get comprehensive financial statistics.""" + from datetime import datetime, timedelta + from sqlalchemy import and_, text + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=7) + month_start = today_start - timedelta(days=30) + + # Today's revenue + stmt_today = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= today_start + ) + ) + today_revenue = await session.execute(stmt_today) + today_amount = today_revenue.scalar() or 0 + + # Week revenue + stmt_week = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= week_start + ) + ) + week_revenue = await session.execute(stmt_week) + week_amount = week_revenue.scalar() or 0 + + # Month revenue + stmt_month = select(func.sum(Payment.amount)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= month_start + ) + ) + month_revenue = await session.execute(stmt_month) + month_amount = month_revenue.scalar() or 0 + + # All time revenue + stmt_all = select(func.sum(Payment.amount)).where(Payment.status == 'succeeded') + all_revenue = await session.execute(stmt_all) + all_amount = all_revenue.scalar() or 0 + + # Count of successful payments today + stmt_count_today = select(func.count(Payment.payment_id)).where( + and_( + Payment.status == 'succeeded', + Payment.created_at >= today_start + ) + ) + today_count = await session.execute(stmt_count_today) + today_payments_count = today_count.scalar() or 0 + + return { + "today_revenue": float(today_amount), + "week_revenue": float(week_amount), + "month_revenue": float(month_amount), + "all_time_revenue": float(all_amount), + "today_payments_count": today_payments_count + } diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index df3ea56..591be40 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -26,6 +26,13 @@ async def get_promo_code_by_id(session: AsyncSession, return await session.get(PromoCode, promo_code_id) +async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]: + """Get promo code by code string (regardless of active status)""" + stmt = select(PromoCode).where(PromoCode.code == code_str.upper()) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def get_active_promo_code_by_code_str( session: AsyncSession, code_str: str) -> Optional[PromoCode]: stmt = select(PromoCode).where( diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 70d3c57..8278b53 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -149,3 +149,70 @@ async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]: stmt = select(User).where(User.panel_user_uuid.is_not(None)) result = await session.execute(stmt) return result.scalars().all() + + +async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: + """Get comprehensive user statistics including active users, trial users, etc.""" + from datetime import datetime, timedelta + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Total users + total_users_stmt = select(func.count(User.user_id)) + total_users = (await session.execute(total_users_stmt)).scalar() or 0 + + # Banned users + banned_users_stmt = select(func.count(User.user_id)).where(User.is_banned == True) + banned_users = (await session.execute(banned_users_stmt)).scalar() or 0 + + # Active users today (users with login activity - for now using registration as proxy) + active_today_stmt = select(func.count(User.user_id)).where( + User.registration_date >= today_start + ) + active_today = (await session.execute(active_today_stmt)).scalar() or 0 + + # Users with active paid subscriptions + paid_subs_stmt = ( + select(func.count(func.distinct(Subscription.user_id))) + .join(User, Subscription.user_id == User.user_id) + .where( + and_( + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.provider.is_not(None) # Not trial + ) + ) + ) + paid_subs_users = (await session.execute(paid_subs_stmt)).scalar() or 0 + + # Users on trial period + trial_subs_stmt = ( + select(func.count(func.distinct(Subscription.user_id))) + .join(User, Subscription.user_id == User.user_id) + .where( + and_( + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.provider.is_(None) # Trial subscriptions + ) + ) + ) + trial_users = (await session.execute(trial_subs_stmt)).scalar() or 0 + + # Inactive users (no active subscription) + inactive_users = total_users - paid_subs_users - trial_users - banned_users + + # Users attracted via referral + referral_users_stmt = select(func.count(User.user_id)).where(User.referred_by_id.is_not(None)) + referral_users = (await session.execute(referral_users_stmt)).scalar() or 0 + + return { + "total_users": total_users, + "banned_users": banned_users, + "active_today": active_today, + "paid_subscriptions": paid_subs_users, + "trial_users": trial_users, + "inactive_users": max(0, inactive_users), + "referral_users": referral_users + } diff --git a/locales/en.json b/locales/en.json index aac840b..28728ca 100644 --- a/locales/en.json +++ b/locales/en.json @@ -82,6 +82,8 @@ "no_bonus_days": "0", "referral_no_bonuses_configured": "\nNo referral bonuses configured.", "referral_link_for_copying_reminder": "The link is above. Press and hold to copy.", + "referral_share_message_button": "📩 Message for friend", + "referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "friend_placeholder": "friend", "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", @@ -92,6 +94,7 @@ "admin_stats_button": "📊 Statistics", "admin_broadcast_button": "📢 Broadcast", "admin_create_promo_button": "🎁 Create Promo", + "admin_create_bulk_promo_button": "📦 Bulk Create", "admin_manage_promos_button": "🛠 Manage Promos", "admin_view_promos_button": "👀 Promo List", "admin_ban_user_button": "🚫 Ban User", @@ -100,11 +103,21 @@ "admin_view_logs_menu_button": "📄 Logs", "admin_sync_panel_button": "🔄 Sync", "admin_unknown_action": "Unknown admin action.", + + "admin_stats_and_monitoring_section": "📊 Statistics & Monitoring", + "admin_user_management_section": "👥 User Management", + "admin_promo_marketing_section": "🎁 Promos & Marketing", + "admin_system_functions_section": "⚙️ System Functions", + "admin_ban_management_section": "🚫 Ban Management", + "admin_users_management_button": "👤 User Management", + "back_to_user_management_button": "⬅️ Back to User Management", "admin_action_cancelled_default": "Action cancelled. Returning to menu.", "admin_action_cancelled_default_alert": "Action cancelled", "back_to_admin_panel_button": "⬅️ Back to Admin", "admin_stats_header": "📊 Bot Statistics", + "admin_enhanced_users_stats_header": "Users", + "admin_financial_stats_header": "Financial Statistics", "admin_stats_users": "👥 Users: Total - {total_users}, Banned - {banned_users}, Active Subs - {active_subs}", "admin_stats_recent_payments_header": "Recent Payments:", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", @@ -193,6 +206,7 @@ "admin_logs_menu_title": "Logs Menu:", "admin_view_all_logs_button": "📜 All Message Logs", "admin_view_user_logs_prompt_button": "👤 User Logs", + "admin_export_logs_csv_button": "📄 Export to CSV", "admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):", "admin_no_logs_found": "No logs found.", "admin_log_entry_format": "{timestamp_str} - {user_display} (ID: {user_id})\n {event_type}: {content_preview}", @@ -223,5 +237,67 @@ "admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.", "admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).", - "error_unknown": "An unknown error occurred." + "error_unknown": "An unknown error occurred.", + + "admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", + "admin_user_card_title": "User Card", + "admin_user_subscription_info": "Subscription Information:", + "admin_user_reset_trial_button": "🔄 Reset Trial", + "admin_user_add_subscription_button": "➕ Add Days", + "admin_user_toggle_ban_button": "🚫 Block/Unblock", + "admin_user_send_message_button": "✉️ Send Message", + "admin_user_view_logs_button": "📜 User Actions", + "admin_user_refresh_button": "🔄 Refresh", + "admin_user_search_new_button": "🔍 Find Another", + "admin_user_view_all_logs_button": "📋 All Actions", + "admin_user_back_to_card_button": "🔙 Back to Card", + + "admin_user_not_found": "❌ User not found: {input}", + "admin_user_not_found_action": "User not found", + "admin_user_card_error": "❌ Error displaying user card", + "admin_user_trial_reset_success": "✅ Trial reset! User can activate trial again.", + "admin_user_trial_reset_error": "❌ Error resetting trial", + "admin_user_add_subscription_prompt": "➕ Adding subscription days for user {user_id}\n\nEnter number of days to add:", + "admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.", + "admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}", + "admin_user_subscription_added_error": "❌ Error adding subscription days", + "admin_user_ban_toggle_success": "✅ User {status}", + "admin_user_ban_toggle_error": "❌ Error changing ban status", + "admin_user_send_message_prompt": "✉️ Sending message to user {user_id}\n\nEnter message text:", + "admin_user_message_too_long": "❌ Message too long (maximum 4000 characters)", + "admin_user_message_sent_success": "✅ Message sent to user {user_id}", + "admin_user_message_sent_error": "❌ Error sending message", + "admin_user_no_logs": "📜 User has no actions", + "admin_user_logs_error": "❌ Error loading user actions", + "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", + + "inline_referral_message": "🚀 Hey! Try this awesome VPN service!\n\n✨ Fast and reliable\n🔒 Complete anonymity\n🌍 Servers worldwide\n💎 Free trial period\n\nCheck it out: {referral_link}", + "inline_referral_title": "🎁 Invite a Friend", + "inline_referral_description": "Share referral link to get bonuses", + + "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", + "inline_admin_user_stats_title": "👥 User Statistics", + "inline_admin_user_stats_desc": "Total: {total}, Active: {paid}", + + "inline_financial_stats_message": "💰 Financial Statistics\n\n📅 Today: {today:.2f} RUB\n ({today_count} payments)\n📅 Week: {week:.2f} RUB\n📅 Month: {month:.2f} RUB\n🏆 All time: {all_time:.2f} RUB", + "inline_admin_financial_stats_title": "💰 Financial Statistics", + "inline_admin_financial_stats_desc": "Today: {today:.2f} RUB", + + "inline_system_stats_message": "🖥 System Statistics\n\n🟢 Online: {online}\n🔴 Offline: {offline}\n⏰ Expired: {expired}\n👥 Total users: {total}", + "inline_admin_system_stats_title": "🖥 System Statistics", + "inline_admin_system_stats_desc": "Online: {online}, Offline: {offline}", + + "inline_admin_help_message": "🤖 Bot Inline Mode\n\n📱 Available commands:\n\n🎁 ref - share referral link\n👥 stat - user statistics\n💰 financial - financial statistics\n🖥 system - system statistics\n\n💡 Just type @{bot_username} and start typing a command in any chat!", + "inline_admin_help_title": "🤖 Inline Help (Admin)", + "inline_admin_help_desc": "Available commands: ref, stat, financial, system", + + "inline_user_help_message": "🤖 Bot Inline Mode\n\n📱 Available commands:\n\n🎁 ref - share referral link\n\n💡 Just type @{bot_username} and start typing 'ref' in any chat!", + "inline_user_help_title": "🤖 Inline Help", + "inline_user_help_desc": "Available command: ref (referral link)", + + "log_referral_suffix": " (referral from {referrer_id})", + "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", + "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", + "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}" } diff --git a/locales/ru.json b/locales/ru.json index 1c9c209..729d091 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -82,6 +82,8 @@ "no_bonus_days": "0", "referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.", "referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.", + "referral_share_message_button": "📩 Сообщение для друга", + "referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "friend_placeholder": "друг", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", @@ -92,6 +94,7 @@ "admin_stats_button": "📊 Статистика", "admin_broadcast_button": "📢 Рассылка", "admin_create_promo_button": "🎁 Создать промо", + "admin_create_bulk_promo_button": "📦 Массовое создание", "admin_manage_promos_button": "🛠 Управление промо", "admin_view_promos_button": "👀 Список промо", "admin_ban_user_button": "🚫 Забанить", @@ -100,11 +103,21 @@ "admin_view_logs_menu_button": "📄 Логи", "admin_sync_panel_button": "🔄 Синхронизация", "admin_unknown_action": "Неизвестное действие администратора.", + + "admin_stats_and_monitoring_section": "📊 Статистика и мониторинг", + "admin_user_management_section": "👥 Управление пользователями", + "admin_promo_marketing_section": "🎁 Промокоды и маркетинг", + "admin_system_functions_section": "⚙️ Системные функции", + "admin_ban_management_section": "🚫 Управление блокировками", + "admin_users_management_button": "👤 Управление пользователями", + "back_to_user_management_button": "⬅️ К управлению пользователями", "admin_action_cancelled_default": "Действие отменено. Возврат в меню.", "admin_action_cancelled_default_alert": "Действие отменено", "back_to_admin_panel_button": "⬅️ В админку", "admin_stats_header": "📊 Статистика Бота", + "admin_enhanced_users_stats_header": "Пользователи", + "admin_financial_stats_header": "Финансовая статистика", "admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}", "admin_stats_recent_payments_header": "Последние платежи:", "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", @@ -193,6 +206,7 @@ "admin_logs_menu_title": "Меню логов:", "admin_view_all_logs_button": "📜 Все логи сообщений", "admin_view_user_logs_prompt_button": "👤 Логи пользователя", + "admin_export_logs_csv_button": "📄 Экспорт в CSV", "admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):", "admin_no_logs_found": "Логи не найдены.", "admin_log_entry_format": "{timestamp_str} - {user_display} (ID: {user_id})\n {event_type}: {content_preview}", @@ -223,5 +237,67 @@ "admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.", "admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)", - "error_unknown": "Произошла неизвестная ошибка." + "error_unknown": "Произошла неизвестная ошибка.", + + "admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:", + "admin_user_card_title": "Карточка пользователя", + "admin_user_subscription_info": "Информация о подписке:", + "admin_user_reset_trial_button": "🔄 Сбросить триал", + "admin_user_add_subscription_button": "➕ Добавить дни", + "admin_user_toggle_ban_button": "🚫 Заблокировать/Разблокировать", + "admin_user_send_message_button": "✉️ Отправить сообщение", + "admin_user_view_logs_button": "📜 Действия пользователя", + "admin_user_refresh_button": "🔄 Обновить", + "admin_user_search_new_button": "🔍 Найти другого", + "admin_user_view_all_logs_button": "📋 Все действия", + "admin_user_back_to_card_button": "🔙 К карточке", + + "admin_user_not_found": "❌ Пользователь не найден: {input}", + "admin_user_not_found_action": "Пользователь не найден", + "admin_user_card_error": "❌ Ошибка отображения карточки пользователя", + "admin_user_trial_reset_success": "✅ Триал сброшен! Пользователь может активировать триал заново.", + "admin_user_trial_reset_error": "❌ Ошибка сброса триала", + "admin_user_add_subscription_prompt": "➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:", + "admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.", + "admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}", + "admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки", + "admin_user_ban_toggle_success": "✅ Пользователь {status}", + "admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки", + "admin_user_send_message_prompt": "✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", + "admin_user_message_too_long": "❌ Сообщение слишком длинное (максимум 4000 символов)", + "admin_user_message_sent_success": "✅ Сообщение отправлено пользователю {user_id}", + "admin_user_message_sent_error": "❌ Ошибка отправки сообщения", + "admin_user_no_logs": "📜 У пользователя нет действий", + "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", + "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", + + "inline_referral_message": "🚀 Привет! Попробуй этот крутой VPN сервис!\n\n✨ Быстрый и надежный\n🔒 Полная анонимность\n🌍 Серверы по всему миру\n💎 Бесплатный пробный период\n\nПереходи по ссылке: {referral_link}", + "inline_referral_title": "🎁 Пригласить друга", + "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", + + "inline_user_stats_message": "👥 Статистика пользователей\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 По реферальной программе: {referral}", + "inline_admin_user_stats_title": "👥 Статистика пользователей", + "inline_admin_user_stats_desc": "Всего: {total}, Активных: {paid}", + + "inline_financial_stats_message": "💰 Финансовая статистика\n\n📅 За сегодня: {today:.2f} RUB\n ({today_count} платежей)\n📅 За неделю: {week:.2f} RUB\n📅 За месяц: {month:.2f} RUB\n🏆 За все время: {all_time:.2f} RUB", + "inline_admin_financial_stats_title": "💰 Финансовая статистика", + "inline_admin_financial_stats_desc": "Сегодня: {today:.2f} RUB", + + "inline_system_stats_message": "🖥 Системная статистика\n\n🟢 Онлайн: {online}\n🔴 Офлайн: {offline}\n⏰ Истекшие: {expired}\n👥 Всего пользователей: {total}", + "inline_admin_system_stats_title": "🖥 Системная статистика", + "inline_admin_system_stats_desc": "Онлайн: {online}, Офлайн: {offline}", + + "inline_admin_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n👥 стат/stat - статистика пользователей\n💰 финансы - финансовая статистика\n🖥 система - системная статистика\n\n💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!", + "inline_admin_help_title": "🤖 Inline помощь (Админ)", + "inline_admin_help_desc": "Доступны команды: реф, стат, финансы, система", + + "inline_user_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n\n💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!", + "inline_user_help_title": "🤖 Inline помощь", + "inline_user_help_desc": "Доступна команда: реф (реферальная ссылка)", + + "log_referral_suffix": " (реферал от {referrer_id})", + "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", + "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", + "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}" } From ffa61f673dedcd48fe6bfc268e378f761a6de0ad Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 17:32:03 +0300 Subject: [PATCH 02/26] Add Docker Buildx setup to development workflow - Integrated Docker Buildx setup step in the GitHub Actions workflow for enhanced multi-platform builds. - This addition streamlines the Docker image building process, improving efficiency and flexibility. --- .github/workflows/dev-docker-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/dev-docker-build.yml b/.github/workflows/dev-docker-build.yml index eb0706f..96a0358 100644 --- a/.github/workflows/dev-docker-build.yml +++ b/.github/workflows/dev-docker-build.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Container Registry uses: docker/login-action@v3 with: From 39ba57951c6967f7efacec9697c2ac7df8d8a813 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 17:37:29 +0300 Subject: [PATCH 03/26] Add field validation for optional integer settings - Introduced a field validator for LOG_CHAT_ID and LOG_THREAD_ID to convert empty strings to None, ensuring proper handling of optional integer fields. - This enhancement improves data integrity and validation within the settings configuration. --- config/settings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/config/settings.py b/config/settings.py index ede723b..37df041 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,6 +1,6 @@ import logging from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic import Field, ValidationError, computed_field +from pydantic import Field, ValidationError, computed_field, field_validator from typing import Optional, List, Dict, Any @@ -293,6 +293,14 @@ class Settings(BaseSettings): LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)") + @field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before') + @classmethod + def validate_optional_int_fields(cls, v): + """Convert empty strings to None for optional integer fields""" + if isinstance(v, str) and v.strip() == '': + return None + return v + # Notification types LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations") LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments") From 0f26c9bdba76d52bf6b7cd2b8a1f63a98d8bb0da Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 17:51:37 +0300 Subject: [PATCH 04/26] Implement user subscription deletion and banned users management - Replaced the trial reset functionality to delete all user subscriptions instead of deactivating them, ensuring a complete reset of trial eligibility. - Added a new method to retrieve banned users from the database, enhancing the admin's ability to manage user bans. - Updated user management handlers to utilize the new subscription deletion and banned users retrieval features. - Enhanced localization files to support new messages related to banned users. --- bot/handlers/admin/user_management.py | 61 ++++++++++++++++++++++++--- db/dal/subscription_dal.py | 12 ++++++ db/dal/user_dal.py | 11 +++++ locales/en.json | 26 +++++++++++- locales/ru.json | 26 +++++++++++- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 16e7b2e..21032f5 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -291,8 +291,8 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User, _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) try: - # Deactivate all user subscriptions - await subscription_dal.deactivate_all_user_subscriptions(session, user.user_id) + # Delete all user subscriptions to reset trial eligibility + await subscription_dal.delete_all_user_subscriptions(session, user.user_id) await session.commit() await callback.answer(_( @@ -362,7 +362,10 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, # Refresh user card with updated ban status user.is_banned = new_ban_status # Update local object - await handle_refresh_user_card(callback, user, None, session, i18n_instance, lang) + from config.settings import Settings + settings = Settings() + subscription_service = SubscriptionService(settings) + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) except Exception as e: logging.error(f"Error toggling ban for user {user.user_id}: {e}") @@ -624,8 +627,9 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte user_id=target_user_id )) - # Show user card again - user_card_text = await format_user_card(target_user, session, None, i18n, current_lang) + # Show user card again + subscription_service = SubscriptionService(settings) + user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang) keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) await message.answer( @@ -641,4 +645,49 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte default="❌ Ошибка отправки сообщения" )) - await state.clear() \ No newline at end of file + await state.clear() + + +async def view_banned_users_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Display list of banned users""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing banned users list.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + # Get banned users + banned_users = await user_dal.get_banned_users(session) + + if not banned_users: + message_text = _( + "admin_banned_users_empty", + default="📋 Заблокированные пользователи\n\nСписок пуст" + ) + else: + user_list = [] + for user in banned_users: + display_name = user.telegram_first_name or "Unknown" + if user.telegram_username: + display_name = f"@{user.telegram_username}" + user_list.append(f"• {display_name} (ID: {user.user_id})") + + message_text = _( + "admin_banned_users_list", + default="📋 Заблокированные пользователи ({count}):\n\n{users}", + count=len(banned_users), + users="\n".join(user_list) + ) + + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + except Exception as e: + logging.error(f"Error displaying banned users: {e}") + await callback.answer("Error loading banned users", show_alert=True) \ No newline at end of file diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 105303c..66d4cf4 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -144,6 +144,18 @@ async def deactivate_all_user_subscriptions( return result.rowcount +async def delete_all_user_subscriptions( + session: AsyncSession, user_id: int) -> int: + """Completely delete all user subscriptions (for trial reset)""" + stmt = delete(Subscription).where(Subscription.user_id == user_id) + result = await session.execute(stmt) + if result.rowcount > 0: + logging.info( + f"Deleted {result.rowcount} subscription records for user {user_id} for trial reset." + ) + return result.rowcount + + async def update_subscription_end_date( session: AsyncSession, subscription_id: int, new_end_date: datetime) -> Optional[Subscription]: diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index 8278b53..b661fff 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -93,6 +93,17 @@ async def set_user_ban_status( return False +async def get_banned_users(session: AsyncSession) -> List[User]: + """Get all banned users""" + stmt = ( + select(User) + .where(User.is_banned == True) + .order_by(User.registration_date.desc()) + ) + result = await session.execute(stmt) + return result.scalars().all() + + async def get_banned_users_paginated( session: AsyncSession, limit: int, offset: int ) -> Tuple[List[User], int]: diff --git a/locales/en.json b/locales/en.json index 28728ca..9290905 100644 --- a/locales/en.json +++ b/locales/en.json @@ -299,5 +299,29 @@ "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", - "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}" + "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", + + "admin_general_cancel_operation": "Operation cancelled ❌", + "admin_promo_codes_menu_bulk_create": "📦 Bulk Creation", + "admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\nquantity|days|description\n\nExample:\n10|7|Weekly promo", + "admin_bulk_promo_invalid_format": "❌ Invalid format. Use: quantity|days|description", + "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!\n\nDescription: {description}\nCodes: {codes}", + + "admin_logs_export_csv": "📄 Export to CSV", + "admin_logs_csv_export_started": "📄 Starting log export to CSV...", + "admin_logs_csv_export_success": "✅ Logs exported! File attached above.", + + "admin_csv_header_log_id": "Log ID", + "admin_csv_header_timestamp": "Timestamp", + "admin_csv_header_user_id": "User ID", + "admin_csv_header_telegram_username": "Username", + "admin_csv_header_telegram_first_name": "First Name", + "admin_csv_header_event_type": "Event Type", + "admin_csv_header_content": "Content", + "admin_csv_header_is_admin_event": "Admin Event", + "admin_csv_header_target_user_id": "Target User ID", + "admin_csv_header_raw_update_preview": "Update Preview", + + "admin_banned_users_empty": "📋 Banned Users\n\nList is empty", + "admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}" } diff --git a/locales/ru.json b/locales/ru.json index 729d091..220cf96 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -299,5 +299,29 @@ "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", - "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}" + "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", + + "admin_general_cancel_operation": "Операция отменена ❌", + "admin_promo_codes_menu_bulk_create": "📦 Массовое создание", + "admin_bulk_promo_create_prompt": "📦 Массовое создание промокодов\n\nВведите данные в формате:\nколичество|дни|описание\n\nПример:\n10|7|Промо на неделю", + "admin_bulk_promo_invalid_format": "❌ Неверный формат. Используйте: количество|дни|описание", + "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!\n\nОписание: {description}\nПромокоды: {codes}", + + "admin_logs_export_csv": "📄 Экспорт в CSV", + "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", + "admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.", + + "admin_csv_header_log_id": "ID Лога", + "admin_csv_header_timestamp": "Время", + "admin_csv_header_user_id": "ID Пользователя", + "admin_csv_header_telegram_username": "Username", + "admin_csv_header_telegram_first_name": "Имя", + "admin_csv_header_event_type": "Тип события", + "admin_csv_header_content": "Содержание", + "admin_csv_header_is_admin_event": "Админ событие", + "admin_csv_header_target_user_id": "ID Целевого юзера", + "admin_csv_header_raw_update_preview": "Превью обновления", + + "admin_banned_users_empty": "📋 Заблокированные пользователи\n\nСписок пуст", + "admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}" } From 4f6822f8e4a64e9954ded3a028d596eef730a99b Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 18:20:47 +0300 Subject: [PATCH 05/26] Enhance promo code management and add bulk creation functionality - Refactored the promo code creation process into a step-by-step flow, improving user guidance and validation. - Introduced bulk promo code creation with a structured multi-step process, allowing admins to create multiple codes efficiently. - Added new states for managing the promo code creation process and bulk operations. - Implemented panel statistics retrieval to provide admins with insights into system performance and user activity. - Updated localization files to support new messages and prompts related to promo code management and statistics. --- bot/handlers/admin/promo_codes.py | 939 +++++++++++++++++++++++++++--- bot/handlers/admin/statistics.py | 44 ++ bot/services/panel_api_service.py | 40 ++ bot/states/admin_states.py | 9 + locales/en.json | 39 +- locales/ru.json | 39 +- 6 files changed, 1022 insertions(+), 88 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 227d239..9775ecf 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -32,29 +32,35 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - prompt_text = _("admin_promo_create_prompt", - example_format="MYPROMO20 7 100 30") + # Step 1: Ask for promo code + prompt_text = _( + "admin_promo_step1_code", + default="🎟 Создание промокода\n\nШаг 1 из 5: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" + ) try: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") except Exception as e: logging.warning( f"Could not edit message for promo prompt: {e}. Sending new.") await callback.message.answer( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") await callback.answer() - await state.set_state(AdminStates.waiting_for_promo_details) + await state.set_state(AdminStates.waiting_for_promo_code) -@router.message(AdminStates.waiting_for_promo_details, F.text) -async def process_promo_code_details_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): +# Step 1: Process promo code +@router.message(AdminStates.waiting_for_promo_code, F.text) +async def process_promo_code_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: @@ -62,87 +68,457 @@ async def process_promo_code_details_handler(message: types.Message, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - if not message.text: - await message.answer(_("admin_promo_invalid_format")) - return - - parts = message.text.strip().split() - if not (3 <= len(parts) <= 4): - await message.answer(_("admin_promo_invalid_format")) - return - try: - code_str = parts[0].upper() + code_str = message.text.strip().upper() if not (3 <= len(code_str) <= 30 and code_str.isalnum()): - raise ValueError(_("admin_promo_invalid_code_format")) + await message.answer(_( + "admin_promo_invalid_code_format", + default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + )) + return + + # Check if code already exists + existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) + if existing_promo: + await message.answer(_( + "admin_promo_code_already_exists", + default="❌ Промокод с таким кодом уже существует" + )) + return + + await state.update_data(promo_code=code_str) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_promo_step2_bonus_days", + default="🎟 Создание промокода\n\nШаг 2 из 5: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + code=code_str + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_bonus_days) + + except Exception as e: + logging.error(f"Error processing promo code: {e}") + await message.answer(_( + "admin_promo_processing_error", + default="❌ Ошибка обработки промокода" + )) - bonus_days = int(parts[1]) - max_activations = int(parts[2]) - - valid_until_date: Optional[datetime] = None - valid_until_str_display = _("admin_promo_valid_indefinitely") - - if len(parts) == 4: - valid_days_from_now = int(parts[3]) - if valid_days_from_now <= 0: - raise ValueError(_("admin_promo_invalid_validity_days")) - valid_until_date = datetime.now( - timezone.utc) + timedelta(days=valid_days_from_now) - valid_until_str_display = _( - "admin_promo_valid_until_display", - date=valid_until_date.strftime('%Y-%m-%d')) - - if bonus_days <= 0 or max_activations <= 0: - raise ValueError(_("admin_promo_invalid_bonus_or_activations")) - - except ValueError as e: - await message.answer(_("admin_promo_invalid_values", error=str(e))) +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_promo_bonus_days, F.text) +async def process_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") return - except Exception as e_parse: - logging.error( - f"Error parsing promo details '{message.text}': {e_parse}") - await message.answer(_("admin_promo_invalid_format_general")) - return - - admin_id = message.from_user.id if message.from_user else 0 - - promo_data_to_create = { - "code": code_str, - "bonus_days": bonus_days, - "max_activations": max_activations, - "created_by_admin_id": admin_id, - "valid_until": valid_until_date, - "is_active": True, - "current_activations": 0 - } + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) try: - created_promo = await promo_code_dal.create_promo_code( - session, promo_data_to_create) - await session.commit() + bonus_days = int(message.text.strip()) + if bonus_days <= 0 or bonus_days > 365: + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Бонусные дни должны быть от 1 до 365" + )) + return + + data = await state.get_data() + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + prompt_text = _( + "admin_promo_step3_max_activations", + default="🎟 Создание промокода\n\nШаг 3 из 5: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + code=data['promo_code'], + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Бонусные дни должны быть от 1 до 365" + )) - if created_promo: - success_text = _("admin_promo_created_success", - code=created_promo.code, - bonus_days=created_promo.bonus_days, - max_activations=created_promo.max_activations, - valid_until_str=valid_until_str_display) - await message.answer(success_text, - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n)) +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_promo_max_activations, F.text) +async def process_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if max_activations <= 0 or max_activations > 10000: + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальные активации должны быть от 1 до 10000" + )) + return + + data = await state.get_data() + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity days (with buttons) + keyboard = InlineKeyboardBuilder() + keyboard.row( + InlineKeyboardButton( + text=_("admin_promo_unlimited_validity", default="♾️ Неограниченно"), + callback_data="promo_unlimited_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_promo_set_validity", default="⏰ Установить срок"), + callback_data="promo_set_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_panel_back_button", default="⬅️ Назад"), + callback_data="admin_panel_back" + ) + ) + + prompt_text = _( + "admin_promo_step4_validity", + default="🎟 Создание промокода\n\nШаг 4 из 5: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", + code=data['promo_code'], + bonus_days=data['bonus_days'], + max_activations=max_activations + ) + + await message.answer( + prompt_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальные активации должны быть от 1 до 10000" + )) + +# Handle validity choice buttons +@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + await state.update_data(validity_days=None) + await ask_for_promo_description(callback, state, i18n_data, settings) + +@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_promo_enter_validity_days", + default="⏰ Введите количество дней действия промокода (1-365):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + await callback.answer() + +# Step 4: Process validity days (when user chooses to set) +@router.message(AdminStates.waiting_for_promo_validity_days, F.text) +async def process_promo_validity_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if validity_days <= 0 or validity_days > 365: + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Дни действия должны быть от 1 до 365" + )) + return + + await state.update_data(validity_days=validity_days) + + # Create a fake callback for consistency + fake_callback = types.CallbackQuery( + id="fake", + from_user=message.from_user, + chat_instance="fake", + message=message, + data="fake" + ) + + await ask_for_promo_description(fake_callback, state, i18n_data, settings) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Дни действия должны быть от 1 до 365" + )) + +async def ask_for_promo_description(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + """Ask for promo description (final step)""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer("Language service error.", show_alert=True) else: - await message.answer( - _("admin_promo_creation_failed_duplicate", code=code_str)) + await callback_or_message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - except Exception as e_db_create: + data = await state.get_data() + validity_days = data.get('validity_days') + validity_text = "Неограниченно" if validity_days is None else f"{validity_days} дн." + + # Step 5: Ask for description (optional) + keyboard = InlineKeyboardBuilder() + keyboard.row( + InlineKeyboardButton( + text=_("admin_promo_skip_description", default="⏭️ Пропустить"), + callback_data="promo_skip_description" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_panel_back_button", default="⬅️ Назад"), + callback_data="admin_panel_back" + ) + ) + + prompt_text = _( + "admin_promo_step5_description", + default="🎟 Создание промокода\n\nШаг 5 из 5: Описание (опционально)\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\nДействует: {validity}\n\nВведите описание промокода или нажмите \"Пропустить\":", + code=data['promo_code'], + bonus_days=data['bonus_days'], + max_activations=data['max_activations'], + validity=validity_text + ) + + if hasattr(callback_or_message, 'message'): + try: + await callback_or_message.message.edit_text( + prompt_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + except: + await callback_or_message.message.answer( + prompt_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer() + else: + await callback_or_message.answer( + prompt_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + + await state.set_state(AdminStates.waiting_for_promo_description) + +# Handle skip description button +@router.callback_query(F.data == "promo_skip_description", StateFilter(AdminStates.waiting_for_promo_description)) +async def process_promo_skip_description(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(description=None) + await create_promo_code_final(callback, state, i18n_data, settings, session) + +# Step 5: Process description (optional) +@router.message(AdminStates.waiting_for_promo_description, F.text) +async def process_promo_description_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + description = message.text.strip() + if len(description) > 200: + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if i18n: + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + await message.answer(_( + "admin_promo_description_too_long", + default="❌ Описание не должно превышать 200 символов" + )) + else: + await message.answer("Description too long") + return + + await state.update_data(description=description) + + # Create a fake callback for consistency + fake_callback = types.CallbackQuery( + id="fake", + from_user=message.from_user, + chat_instance="fake", + message=message, + data="fake" + ) + + await create_promo_code_final(fake_callback, state, i18n_data, settings, session) + +async def create_promo_code_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step to create the promo code""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer("Language service error.", show_alert=True) + else: + await callback_or_message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + promo_code = data['promo_code'] + bonus_days = data['bonus_days'] + max_activations = data['max_activations'] + validity_days = data.get('validity_days') + description = data.get('description') + + # Create validity date + valid_until_date: Optional[datetime] = None + valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") + + if validity_days: + valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) + valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') + + # Show creating message + creating_text = _( + "admin_promo_creating", + default="⏳ Создаю промокод..." + ) + + if hasattr(callback_or_message, 'message'): + try: + await callback_or_message.message.edit_text(creating_text) + except: + await callback_or_message.message.answer(creating_text) + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer() + else: + await callback_or_message.answer(creating_text) + + # Create promo code + try: + promo_data = { + "code": promo_code, + "bonus_days": bonus_days, + "max_activations": max_activations, + "current_activations": 0, + "is_active": True, + "valid_until": valid_until_date, + "description": description + } + + await promo_code_dal.create_promo_code(session, promo_data) + await session.commit() + + # Send success message + success_text = _( + "admin_promo_created_success", + default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}\n📝 Описание: {description}", + code=promo_code, + days=bonus_days, + max_act=max_activations, + validity=valid_until_str_display, + description=description or "Не указано" + ) + + if hasattr(callback_or_message, 'message'): + target_message = callback_or_message.message + else: + target_message = callback_or_message + + await target_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + except Exception as e: + logging.error(f"Error creating promo code: {e}") await session.rollback() - logging.error( - f"Failed to create promo code '{code_str}' in DB: {e_db_create}", - exc_info=True) - await message.answer(_("admin_promo_creation_failed")) - + error_text = _( + "admin_promo_creation_failed", + default="❌ Ошибка создания промокода: {error}", + error=str(e) + ) + + if hasattr(callback_or_message, 'message'): + target_message = callback_or_message.message + else: + target_message = callback_or_message + + await target_message.answer( + error_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + await state.clear() +# Legacy handler removed - using step-by-step creation now + async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, @@ -341,8 +717,17 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, F.data == "admin_action:main", StateFilter( AdminStates.waiting_for_promo_details, + AdminStates.waiting_for_promo_code, + AdminStates.waiting_for_promo_bonus_days, + AdminStates.waiting_for_promo_max_activations, + AdminStates.waiting_for_promo_validity_days, + AdminStates.waiting_for_promo_description, AdminStates.waiting_for_promo_edit_details, AdminStates.waiting_for_bulk_promo_details, + AdminStates.waiting_for_bulk_promo_quantity, + AdminStates.waiting_for_bulk_promo_bonus_days, + AdminStates.waiting_for_bulk_promo_max_activations, + AdminStates.waiting_for_bulk_promo_validity_days, ), ) async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, @@ -382,24 +767,26 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + # Step 1: Ask for quantity prompt_text = _( - "admin_bulk_promo_create_prompt", - default="📦 Массовое создание промокодов\n\nВведите данные в формате:\n<количество> <бонусные_дни> <максимальное_использование> [дни_действия]\n\nПример: 50 7 100 30\n(создаст 50 промокодов на 7 дней с лимитом 100 активаций каждый, действующих 30 дней)", - example_format="50 7 100 30" + "admin_bulk_promo_step1_quantity", + default="📦 Массовое создание промокодов\n\nШаг 1 из 4: Количество промокодов\n\nВведите количество промокодов для создания (1-1000):" ) try: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") except Exception as e: logging.warning( f"Could not edit message for bulk promo prompt: {e}. Sending new.") await callback.message.answer( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") await callback.answer() - await state.set_state(AdminStates.waiting_for_bulk_promo_details) + await state.set_state(AdminStates.waiting_for_bulk_promo_quantity) def generate_unique_promo_code(length: int = 8) -> str: @@ -410,6 +797,386 @@ def generate_unique_promo_code(length: int = 8) -> str: return ''.join(random.choice(characters) for _ in range(length)) +# Step 1: Process quantity +@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text) +async def process_bulk_promo_quantity_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + quantity = int(message.text.strip()) + if quantity <= 0 or quantity > 1000: + await message.answer(_( + "admin_bulk_promo_invalid_quantity", + default="❌ Количество должно быть от 1 до 1000" + )) + return + + await state.update_data(quantity=quantity) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_bulk_promo_step2_bonus_days", + default="📦 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней (1-365):", + quantity=quantity + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days) + + except ValueError: + await message.answer(_( + "admin_bulk_promo_invalid_quantity", + default="❌ Количество должно быть от 1 до 1000" + )) + +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text) +async def process_bulk_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + bonus_days = int(message.text.strip()) + if bonus_days <= 0 or bonus_days > 365: + await message.answer(_( + "admin_bulk_promo_invalid_bonus_days", + default="❌ Бонусные дни должны быть от 1 до 365" + )) + return + + data = await state.get_data() + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + prompt_text = _( + "admin_bulk_promo_step3_max_activations", + default="📦 Массовое создание промокодов\n\nШаг 3 из 4: Максимальные активации\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", + quantity=data['quantity'], + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_bulk_promo_invalid_bonus_days", + default="❌ Бонусные дни должны быть от 1 до 365" + )) + +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text) +async def process_bulk_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if max_activations <= 0 or max_activations > 10000: + await message.answer(_( + "admin_bulk_promo_invalid_max_activations", + default="❌ Максимальные активации должны быть от 1 до 10000" + )) + return + + data = await state.get_data() + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity days (optional) + keyboard = InlineKeyboardBuilder() + keyboard.row( + InlineKeyboardButton( + text=_("admin_bulk_promo_unlimited_validity", default="♾️ Неограниченно"), + callback_data="bulk_promo_unlimited_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_bulk_promo_set_validity", default="⏰ Установить срок"), + callback_data="bulk_promo_set_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_panel_back_button", default="⬅️ Назад"), + callback_data="admin_panel_back" + ) + ) + + prompt_text = _( + "admin_bulk_promo_step4_validity", + default="📦 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокодов:", + quantity=data['quantity'], + bonus_days=data['bonus_days'], + max_activations=max_activations + ) + + await message.answer( + prompt_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_bulk_promo_invalid_max_activations", + default="❌ Максимальные активации должны быть от 1 до 10000" + )) + +# Handle validity choice buttons +@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(validity_days=None) + await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session) + +@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_bulk_promo_enter_validity_days", + default="⏰ Введите количество дней действия промокодов (1-365):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + await callback.answer() + +# Step 4: Process validity days (when user chooses to set) +@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text) +async def process_bulk_promo_validity_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if validity_days <= 0 or validity_days > 365: + await message.answer(_( + "admin_bulk_promo_invalid_validity_days", + default="❌ Дни действия должны быть от 1 до 365" + )) + return + + await state.update_data(validity_days=validity_days) + + # Create a fake callback for consistency + fake_callback = types.CallbackQuery( + id="fake", + from_user=message.from_user, + chat_instance="fake", + message=message, + data="fake" + ) + + await create_bulk_promo_codes_final(fake_callback, state, i18n_data, settings, session) + + except ValueError: + await message.answer(_( + "admin_bulk_promo_invalid_validity_days", + default="❌ Дни действия должны быть от 1 до 365" + )) + +async def create_bulk_promo_codes_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step to create all promo codes""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer("Language service error.", show_alert=True) + else: + await callback_or_message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + quantity = data['quantity'] + bonus_days = data['bonus_days'] + max_activations = data['max_activations'] + validity_days = data.get('validity_days') + + # Create validity date + valid_until_date: Optional[datetime] = None + valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") + + if validity_days: + valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) + valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') + + # Show creating message + creating_text = _( + "admin_bulk_promo_creating", + default="⏳ Создаю {quantity} промокодов...", + quantity=quantity + ) + + if hasattr(callback_or_message, 'message'): + try: + await callback_or_message.message.edit_text(creating_text) + except: + await callback_or_message.message.answer(creating_text) + if hasattr(callback_or_message, 'answer'): + await callback_or_message.answer() + else: + await callback_or_message.answer(creating_text) + + # Generate and create promo codes + created_codes = [] + try: + for i in range(quantity): + # Generate unique code + promo_code = generate_unique_promo_code() + + # Ensure uniqueness + existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) + retries = 0 + while existing_promo and retries < 10: + promo_code = generate_unique_promo_code() + existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) + retries += 1 + + if retries >= 10: + raise Exception("Не удалось создать уникальный промокод") + + # Create promo code + promo_data = { + "code": promo_code, + "bonus_days": bonus_days, + "max_activations": max_activations, + "current_activations": 0, + "is_active": True, + "valid_until": valid_until_date, + "description": f"Bulk created #{i+1}/{quantity}" + } + + await promo_code_dal.create_promo_code(session, promo_data) + created_codes.append(promo_code) + + await session.commit() + + # Send success message + codes_text = " ".join(created_codes) + success_text = _( + "admin_bulk_promo_created_success", + default="✅ Создано {count} промокодов на {days} дней!\n\nДействуют до: {validity}\nМакс. активации: {max_act}\n\nПромокоды:\n{codes}", + count=quantity, + days=bonus_days, + validity=valid_until_str_display, + max_act=max_activations, + codes=codes_text[:3500] + "..." if len(codes_text) > 3500 else codes_text + ) + + if hasattr(callback_or_message, 'message'): + target_message = callback_or_message.message + else: + target_message = callback_or_message + + await target_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + # Send codes as file if too many + if len(codes_text) > 3500: + codes_file_content = "\n".join(created_codes) + codes_file = types.BufferedInputFile( + codes_file_content.encode('utf-8'), + filename=f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + ) + + await target_message.answer_document( + codes_file, + caption=_( + "admin_bulk_promo_codes_file", + default="📄 Все созданные промокоды в файле", + count=quantity + ) + ) + + except Exception as e: + logging.error(f"Error creating bulk promo codes: {e}") + await session.rollback() + error_text = _( + "admin_bulk_promo_creation_failed", + default="❌ Ошибка создания промокодов: {error}", + error=str(e) + ) + + if hasattr(callback_or_message, 'message'): + target_message = callback_or_message.message + else: + target_message = callback_or_message + + await target_message.answer( + error_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + await state.clear() + @router.message(AdminStates.waiting_for_bulk_promo_details, F.text) async def process_bulk_promo_details_handler(message: types.Message, state: FSMContext, diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 93d7b32..bcc7dce 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -8,6 +8,7 @@ from config.settings import Settings from db.dal import user_dal, payment_dal, panel_sync_dal from db.models import Payment, PanelSyncStatus +from bot.services.panel_api_service import PanelApiService from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard from bot.middlewares.i18n import JsonI18n @@ -137,6 +138,49 @@ async def show_statistics_handler(callback: types.CallbackQuery, else: stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}") + # Panel Statistics + try: + panel_service = PanelApiService(settings) + + # Get panel system statistics + panel_stats = await panel_service.get_panel_statistics() + nodes_stats = await panel_service.get_nodes_statistics() + online_count = await panel_service.get_online_users_count() + users_activity = await panel_service.get_users_activity_stats() + + stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + + if online_count is not None: + stats_text_parts.append(f"🟢 Онлайн сейчас: {online_count}") + + if users_activity: + today_connected = users_activity.get('today_connected', 'N/A') + week_connected = users_activity.get('week_connected', 'N/A') + never_connected = users_activity.get('never_connected', 'N/A') + stats_text_parts.append(f"📅 Подключались сегодня: {today_connected}") + stats_text_parts.append(f"📅 Подключались за неделю: {week_connected}") + stats_text_parts.append(f"❌ Никогда не подключались: {never_connected}") + + if nodes_stats: + active_nodes = len([node for node in nodes_stats if node.get('status') == 'active']) + total_nodes = len(nodes_stats) + stats_text_parts.append(f"🔗 Активных нод: {active_nodes}/{total_nodes}") + + if panel_stats: + system_info = panel_stats.get('system', {}) + if system_info: + memory_usage = system_info.get('memory_usage_percent', 'N/A') + cpu_usage = system_info.get('cpu_usage_percent', 'N/A') + stats_text_parts.append(f"💾 Использование RAM: {memory_usage}%") + stats_text_parts.append(f"🔄 Загрузка CPU: {cpu_usage}%") + + await panel_service.close() + + except Exception as e: + logging.warning(f"Failed to fetch panel statistics: {e}") + stats_text_parts.append(f"\n🖥 Статистика панели") + stats_text_parts.append("❌ Не удалось получить данные с панели") + final_text = "\n".join(stats_text_parts) try: diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 9ffb6d7..a09f499 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -460,3 +460,43 @@ class PanelApiService: async def get_bot_db_last_sync_status( self, session: AsyncSession) -> Optional[PanelSyncStatus]: return await panel_sync_dal.get_panel_sync_status(session) + + + async def get_panel_statistics(self) -> Optional[Dict[str, Any]]: + """Get general panel statistics""" + response_data = await self._request("GET", "/admin/stats", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None + + + async def get_nodes_statistics(self) -> Optional[List[Dict[str, Any]]]: + """Get nodes statistics""" + response_data = await self._request("GET", "/admin/nodes/stats", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response", {}).get("nodes", []) + return None + + + async def get_system_info(self) -> Optional[Dict[str, Any]]: + """Get system information""" + response_data = await self._request("GET", "/admin/system/info", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None + + + async def get_online_users_count(self) -> Optional[int]: + """Get count of currently online users""" + response_data = await self._request("GET", "/admin/stats/online-users", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response", {}).get("count", 0) + return None + + + async def get_users_activity_stats(self) -> Optional[Dict[str, Any]]: + """Get users activity statistics (today, week, never connected)""" + response_data = await self._request("GET", "/admin/stats/users-activity", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None \ No newline at end of file diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index e7b4c63..21ee390 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -6,8 +6,17 @@ class AdminStates(StatesGroup): waiting_for_broadcast_message = State() confirming_broadcast = State() waiting_for_promo_details = State() + waiting_for_promo_code = State() + waiting_for_promo_bonus_days = State() + waiting_for_promo_max_activations = State() + waiting_for_promo_validity_days = State() + waiting_for_promo_description = State() waiting_for_promo_edit_details = State() waiting_for_bulk_promo_details = State() + waiting_for_bulk_promo_quantity = State() + waiting_for_bulk_promo_bonus_days = State() + waiting_for_bulk_promo_max_activations = State() + waiting_for_bulk_promo_validity_days = State() waiting_for_user_id_to_ban = State() waiting_for_user_id_to_unban = State() diff --git a/locales/en.json b/locales/en.json index 9290905..f02392b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -323,5 +323,42 @@ "admin_csv_header_raw_update_preview": "Update Preview", "admin_banned_users_empty": "📋 Banned Users\n\nList is empty", - "admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}" + "admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}", + + "admin_panel_stats_header": "Panel Statistics", + "admin_bulk_promo_invalid_values": "❌ Invalid values: {error}", + + "admin_bulk_promo_step1_quantity": "📦 Bulk Promo Code Creation\n\nStep 1 of 4: Quantity\n\nEnter the number of promo codes to create (1-1000):", + "admin_bulk_promo_step2_bonus_days": "📦 Bulk Promo Code Creation\n\nStep 2 of 4: Bonus Days\n\nQuantity: {quantity}\n\nEnter the number of bonus days (1-365):", + "admin_bulk_promo_step3_max_activations": "📦 Bulk Promo Code Creation\n\nStep 3 of 4: Max Activations\n\nQuantity: {quantity}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations for each promo code (1-10000):", + "admin_bulk_promo_step4_validity": "📦 Bulk Promo Code Creation\n\nStep 4 of 4: Validity Period\n\nQuantity: {quantity}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for promo codes:", + + "admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000", + "admin_bulk_promo_invalid_bonus_days": "❌ Bonus days must be between 1 and 365", + "admin_bulk_promo_invalid_max_activations": "❌ Max activations must be between 1 and 10000", + "admin_bulk_promo_invalid_validity_days": "❌ Validity days must be between 1 and 365", + + "admin_bulk_promo_unlimited_validity": "♾️ Unlimited", + "admin_bulk_promo_set_validity": "⏰ Set Period", + "admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):", + + "admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...", + + "admin_promo_step1_code": "🎟 Create Promo Code\n\nStep 1 of 5: Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", + "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 5: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", + "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 5: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", + "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 5: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", + "admin_promo_step5_description": "🎟 Create Promo Code\n\nStep 5 of 5: Description (optional)\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\nValid until: {validity}\n\nEnter promo code description or click \"Skip\":", + + "admin_promo_invalid_code_format": "❌ Promo code must contain 3-30 characters (letters and numbers only)", + "admin_promo_code_already_exists": "❌ A promo code with this code already exists", + "admin_promo_processing_error": "❌ Error processing promo code", + "admin_promo_unlimited_validity": "♾️ Unlimited", + "admin_promo_set_validity": "⏰ Set Period", + "admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", + "admin_promo_skip_description": "⏭️ Skip", + "admin_promo_description_too_long": "❌ Description must not exceed 200 characters", + "admin_promo_creating": "⏳ Creating promo code...", + "admin_promo_created_success": "✅ Promo code created!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}\n📝 Description: {description}", + "admin_promo_creation_failed": "❌ Error creating promo code: {error}" } diff --git a/locales/ru.json b/locales/ru.json index 220cf96..a0c6bbf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -323,5 +323,42 @@ "admin_csv_header_raw_update_preview": "Превью обновления", "admin_banned_users_empty": "📋 Заблокированные пользователи\n\nСписок пуст", - "admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}" + "admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}", + + "admin_panel_stats_header": "Статистика панели", + "admin_bulk_promo_invalid_values": "❌ Неверные значения: {error}", + + "admin_bulk_promo_step1_quantity": "📦 Массовое создание промокодов\n\nШаг 1 из 4: Количество промокодов\n\nВведите количество промокодов для создания (1-1000):", + "admin_bulk_promo_step2_bonus_days": "📦 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней (1-365):", + "admin_bulk_promo_step3_max_activations": "📦 Массовое создание промокодов\n\nШаг 3 из 4: Максимальные активации\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", + "admin_bulk_promo_step4_validity": "📦 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокодов:", + + "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", + "admin_bulk_promo_invalid_bonus_days": "❌ Бонусные дни должны быть от 1 до 365", + "admin_bulk_promo_invalid_max_activations": "❌ Максимальные активации должны быть от 1 до 10000", + "admin_bulk_promo_invalid_validity_days": "❌ Дни действия должны быть от 1 до 365", + + "admin_bulk_promo_unlimited_validity": "♾️ Неограниченно", + "admin_bulk_promo_set_validity": "⏰ Установить срок", + "admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):", + + "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", + + "admin_promo_step1_code": "🎟 Создание промокода\n\nШаг 1 из 5: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", + "admin_promo_step2_bonus_days": "🎟 Создание промокода\n\nШаг 2 из 5: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 5: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 5: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", + "admin_promo_step5_description": "🎟 Создание промокода\n\nШаг 5 из 5: Описание (опционально)\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\nДействует: {validity}\n\nВведите описание промокода или нажмите \"Пропустить\":", + + "admin_promo_invalid_code_format": "❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)", + "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", + "admin_promo_processing_error": "❌ Ошибка обработки промокода", + "admin_promo_unlimited_validity": "♾️ Неограниченно", + "admin_promo_set_validity": "⏰ Установить срок", + "admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", + "admin_promo_skip_description": "⏭️ Пропустить", + "admin_promo_description_too_long": "❌ Описание не должно превышать 200 символов", + "admin_promo_creating": "⏳ Создаю промокод...", + "admin_promo_created_success": "✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}\n📝 Описание: {description}", + "admin_promo_creation_failed": "❌ Ошибка создания промокода: {error}" } From 973e97c54eee1d8d09424b956d86d76b381783ff Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 18:44:12 +0300 Subject: [PATCH 06/26] Refactor promo code creation process and remove unnecessary handlers - Updated the promo code creation flow to a four-step process, enhancing user guidance and validation. - Removed the optional description step from the promo code creation, streamlining the process. - Updated localization files to reflect changes in the promo code creation steps and success messages. - Enhanced error handling and logging for panel statistics retrieval in the admin statistics handler. --- bot/handlers/admin/promo_codes.py | 310 ++------------------------ bot/handlers/admin/statistics.py | 25 ++- bot/handlers/admin/user_management.py | 8 +- bot/states/admin_states.py | 2 - locales/en.json | 16 +- locales/ru.json | 16 +- 6 files changed, 59 insertions(+), 318 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 9775ecf..b230ec8 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -35,7 +35,7 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery, # Step 1: Ask for promo code prompt_text = _( "admin_promo_step1_code", - default="🎟 Создание промокода\n\nШаг 1 из 5: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" + default="🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" ) try: @@ -91,7 +91,7 @@ async def process_promo_code_handler(message: types.Message, # Step 2: Ask for bonus days prompt_text = _( "admin_promo_step2_bonus_days", - default="🎟 Создание промокода\n\nШаг 2 из 5: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + default="🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", code=code_str ) @@ -137,7 +137,7 @@ async def process_promo_bonus_days_handler(message: types.Message, # Step 3: Ask for max activations prompt_text = _( "admin_promo_step3_max_activations", - default="🎟 Создание промокода\n\nШаг 3 из 5: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + default="🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", code=data['promo_code'], bonus_days=bonus_days ) @@ -203,7 +203,7 @@ async def process_promo_max_activations_handler(message: types.Message, prompt_text = _( "admin_promo_step4_validity", - default="🎟 Создание промокода\n\nШаг 4 из 5: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", + default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", code=data['promo_code'], bonus_days=data['bonus_days'], max_activations=max_activations @@ -227,9 +227,10 @@ async def process_promo_max_activations_handler(message: types.Message, async def process_promo_unlimited_validity(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, - settings: Settings): + settings: Settings, + session: AsyncSession): await state.update_data(validity_days=None) - await ask_for_promo_description(callback, state, i18n_data, settings) + await create_promo_code_final(callback, state, i18n_data, settings, session) @router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) async def process_promo_set_validity(callback: types.CallbackQuery, @@ -293,7 +294,7 @@ async def process_promo_validity_days_handler(message: types.Message, data="fake" ) - await ask_for_promo_description(fake_callback, state, i18n_data, settings) + await create_promo_code_final(fake_callback, state, i18n_data, settings, session) except ValueError: await message.answer(_( @@ -301,116 +302,7 @@ async def process_promo_validity_days_handler(message: types.Message, default="❌ Дни действия должны быть от 1 до 365" )) -async def ask_for_promo_description(callback_or_message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - """Ask for promo description (final step)""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer("Language service error.", show_alert=True) - else: - await callback_or_message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - validity_days = data.get('validity_days') - validity_text = "Неограниченно" if validity_days is None else f"{validity_days} дн." - - # Step 5: Ask for description (optional) - keyboard = InlineKeyboardBuilder() - keyboard.row( - InlineKeyboardButton( - text=_("admin_promo_skip_description", default="⏭️ Пропустить"), - callback_data="promo_skip_description" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_panel_back_button", default="⬅️ Назад"), - callback_data="admin_panel_back" - ) - ) - - prompt_text = _( - "admin_promo_step5_description", - default="🎟 Создание промокода\n\nШаг 5 из 5: Описание (опционально)\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\nДействует: {validity}\n\nВведите описание промокода или нажмите \"Пропустить\":", - code=data['promo_code'], - bonus_days=data['bonus_days'], - max_activations=data['max_activations'], - validity=validity_text - ) - - if hasattr(callback_or_message, 'message'): - try: - await callback_or_message.message.edit_text( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - except: - await callback_or_message.message.answer( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer() - else: - await callback_or_message.answer( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - - await state.set_state(AdminStates.waiting_for_promo_description) - -# Handle skip description button -@router.callback_query(F.data == "promo_skip_description", StateFilter(AdminStates.waiting_for_promo_description)) -async def process_promo_skip_description(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - await state.update_data(description=None) - await create_promo_code_final(callback, state, i18n_data, settings, session) - -# Step 5: Process description (optional) -@router.message(AdminStates.waiting_for_promo_description, F.text) -async def process_promo_description_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - description = message.text.strip() - if len(description) > 200: - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if i18n: - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - await message.answer(_( - "admin_promo_description_too_long", - default="❌ Описание не должно превышать 200 символов" - )) - else: - await message.answer("Description too long") - return - - await state.update_data(description=description) - - # Create a fake callback for consistency - fake_callback = types.CallbackQuery( - id="fake", - from_user=message.from_user, - chat_instance="fake", - message=message, - data="fake" - ) - - await create_promo_code_final(fake_callback, state, i18n_data, settings, session) +# Description handlers removed - not needed for this model async def create_promo_code_final(callback_or_message, state: FSMContext, @@ -433,7 +325,6 @@ async def create_promo_code_final(callback_or_message, bonus_days = data['bonus_days'] max_activations = data['max_activations'] validity_days = data.get('validity_days') - description = data.get('description') # Create validity date valid_until_date: Optional[datetime] = None @@ -468,7 +359,7 @@ async def create_promo_code_final(callback_or_message, "current_activations": 0, "is_active": True, "valid_until": valid_until_date, - "description": description + "created_by_admin_id": callback_or_message.from_user.id if hasattr(callback_or_message, 'from_user') and callback_or_message.from_user else 0 } await promo_code_dal.create_promo_code(session, promo_data) @@ -477,12 +368,11 @@ async def create_promo_code_final(callback_or_message, # Send success message success_text = _( "admin_promo_created_success", - default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}\n📝 Описание: {description}", + default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", code=promo_code, days=bonus_days, max_act=max_activations, - validity=valid_until_str_display, - description=description or "Не указано" + validity=valid_until_str_display ) if hasattr(callback_or_message, 'message'): @@ -721,9 +611,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, AdminStates.waiting_for_promo_bonus_days, AdminStates.waiting_for_promo_max_activations, AdminStates.waiting_for_promo_validity_days, - AdminStates.waiting_for_promo_description, AdminStates.waiting_for_promo_edit_details, - AdminStates.waiting_for_bulk_promo_details, AdminStates.waiting_for_bulk_promo_quantity, AdminStates.waiting_for_bulk_promo_bonus_days, AdminStates.waiting_for_bulk_promo_max_activations, @@ -1108,7 +996,7 @@ async def create_bulk_promo_codes_final(callback_or_message, "current_activations": 0, "is_active": True, "valid_until": valid_until_date, - "description": f"Bulk created #{i+1}/{quantity}" + "created_by_admin_id": callback_or_message.from_user.id if hasattr(callback_or_message, 'from_user') and callback_or_message.from_user else 0 } await promo_code_dal.create_promo_code(session, promo_data) @@ -1177,177 +1065,7 @@ async def create_bulk_promo_codes_final(callback_or_message, await state.clear() -@router.message(AdminStates.waiting_for_bulk_promo_details, F.text) -async def process_bulk_promo_details_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - if not message.text: - await message.answer(_("admin_promo_invalid_format")) - return - - parts = message.text.strip().split() - if not (3 <= len(parts) <= 4): - await message.answer(_( - "admin_bulk_promo_invalid_format", - default="❌ Неверный формат. Используйте: <количество> <бонусные_дни> <максимальное_использование> [дни_действия]" - )) - return - - try: - count = int(parts[0]) - bonus_days = int(parts[1]) - max_activations = int(parts[2]) - - if count <= 0 or count > 1000: - raise ValueError(_("admin_bulk_promo_invalid_count", default="Количество должно быть от 1 до 1000")) - - if bonus_days <= 0 or max_activations <= 0: - raise ValueError(_("admin_promo_invalid_bonus_or_activations")) - - valid_until_date: Optional[datetime] = None - valid_until_str_display = _("admin_promo_valid_indefinitely") - - if len(parts) == 4: - valid_days_from_now = int(parts[3]) - if valid_days_from_now <= 0: - raise ValueError(_("admin_promo_invalid_validity_days")) - valid_until_date = datetime.now( - timezone.utc) + timedelta(days=valid_days_from_now) - valid_until_str_display = _( - "admin_promo_valid_until_display", - date=valid_until_date.strftime('%Y-%m-%d')) - - except ValueError as e: - await message.answer(_( - "admin_bulk_promo_invalid_values", - default="❌ Неверные значения: {error}", - error=str(e) - )) - return - except Exception as e_parse: - logging.error(f"Error parsing bulk promo details '{message.text}': {e_parse}") - await message.answer(_("admin_promo_invalid_format_general")) - return - - admin_id = message.from_user.id if message.from_user else 0 - - # Generate unique codes and create promo codes - created_codes = [] - failed_codes = [] - - await message.answer(_( - "admin_bulk_promo_creating", - default="🔄 Создаю {count} промокодов...", - count=count - )) - - for i in range(count): - try: - # Generate unique code - attempts = 0 - while attempts < 10: # Max 10 attempts to generate unique code - code = generate_unique_promo_code() - - # Check if code already exists - existing_promo = await promo_code_dal.get_promo_code_by_code(session, code) - if not existing_promo: - break - attempts += 1 - - if attempts >= 10: - failed_codes.append(f"Failed to generate unique code #{i+1}") - continue - - promo_data_to_create = { - "code": code, - "bonus_days": bonus_days, - "max_activations": max_activations, - "created_by_admin_id": admin_id, - "valid_until": valid_until_date, - "is_active": True, - "current_activations": 0 - } - - created_promo = await promo_code_dal.create_promo_code( - session, promo_data_to_create) - - if created_promo: - created_codes.append(created_promo.code) - else: - failed_codes.append(f"Failed to create code #{i+1}") - - except Exception as e: - logging.error(f"Error creating bulk promo code #{i+1}: {e}") - failed_codes.append(f"Error creating code #{i+1}: {str(e)}") - - try: - await session.commit() - - # Prepare success message - success_text_parts = [ - _( - "admin_bulk_promo_created_success", - default="✅ Массовое создание завершено!\n\n📦 Создано промокодов: {created_count}\n💎 Бонусных дней: {bonus_days}\n🔄 Макс. активаций каждого: {max_activations}\n⏰ Действительны до: {valid_until}", - created_count=len(created_codes), - bonus_days=bonus_days, - max_activations=max_activations, - valid_until=valid_until_str_display - ) - ] - - if failed_codes: - success_text_parts.append(f"\n❌ Ошибок: {len(failed_codes)}") - - if created_codes: - # Show first few codes as examples - codes_to_show = created_codes[:10] # Show first 10 - success_text_parts.append(f"\n📝 Примеры созданных кодов:") - success_text_parts.append("\n".join([f"• {code}" for code in codes_to_show])) - - if len(created_codes) > 10: - success_text_parts.append(f"... и еще {len(created_codes) - 10} кодов") - - # Send codes as a file if many were created - if len(created_codes) > 20: - try: - codes_text = "\n".join(created_codes) - codes_file = types.BufferedInputFile( - codes_text.encode('utf-8'), - filename=f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - ) - await message.answer_document( - codes_file, - caption=_( - "admin_bulk_promo_codes_file", - default="📄 Файл со всеми созданными промокодами" - ) - ) - except Exception as e: - logging.error(f"Failed to send codes file: {e}") - - final_text = "\n".join(success_text_parts) - await message.answer( - final_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - - except Exception as e_db_commit: - await session.rollback() - logging.error(f"Failed to commit bulk promo codes creation: {e_db_commit}", exc_info=True) - await message.answer(_( - "admin_bulk_promo_creation_failed", - default="❌ Ошибка при сохранении промокодов в базу данных" - )) - - await state.clear() +# Old bulk promo handler removed - now using step-by-step process diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index bcc7dce..d7c1af4 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -139,20 +139,27 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}") # Panel Statistics + stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + try: panel_service = PanelApiService(settings) # Get panel system statistics + logging.info("Fetching panel statistics...") panel_stats = await panel_service.get_panel_statistics() nodes_stats = await panel_service.get_nodes_statistics() online_count = await panel_service.get_online_users_count() users_activity = await panel_service.get_users_activity_stats() - stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + logging.info(f"Panel stats response: panel_stats={panel_stats}, nodes_stats={nodes_stats}, online_count={online_count}, users_activity={users_activity}") + # Online users if online_count is not None: stats_text_parts.append(f"🟢 Онлайн сейчас: {online_count}") + else: + stats_text_parts.append(f"🟢 Онлайн сейчас: N/A") + # Users activity if users_activity: today_connected = users_activity.get('today_connected', 'N/A') week_connected = users_activity.get('week_connected', 'N/A') @@ -160,12 +167,18 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append(f"📅 Подключались сегодня: {today_connected}") stats_text_parts.append(f"📅 Подключались за неделю: {week_connected}") stats_text_parts.append(f"❌ Никогда не подключались: {never_connected}") + else: + stats_text_parts.append(f"📅 Активность пользователей: N/A") + # Nodes statistics if nodes_stats: active_nodes = len([node for node in nodes_stats if node.get('status') == 'active']) total_nodes = len(nodes_stats) stats_text_parts.append(f"🔗 Активных нод: {active_nodes}/{total_nodes}") + else: + stats_text_parts.append(f"🔗 Ноды: N/A") + # System statistics if panel_stats: system_info = panel_stats.get('system', {}) if system_info: @@ -173,13 +186,17 @@ async def show_statistics_handler(callback: types.CallbackQuery, cpu_usage = system_info.get('cpu_usage_percent', 'N/A') stats_text_parts.append(f"💾 Использование RAM: {memory_usage}%") stats_text_parts.append(f"🔄 Загрузка CPU: {cpu_usage}%") + else: + stats_text_parts.append(f"💾 Системная информация: N/A") + else: + stats_text_parts.append(f"💾 Системная статистика: N/A") await panel_service.close() except Exception as e: - logging.warning(f"Failed to fetch panel statistics: {e}") - stats_text_parts.append(f"\n🖥 Статистика панели") - stats_text_parts.append("❌ Не удалось получить данные с панели") + logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) + stats_text_parts.append("❌ Ошибка получения данных с панели") + stats_text_parts.append(f"⚠️ Детали: {str(e)}") final_text = "\n".join(stats_text_parts) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 21032f5..65daa9d 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -363,8 +363,10 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, # Refresh user card with updated ban status user.is_banned = new_ban_status # Update local object from config.settings import Settings + from bot.services.panel_api_service import PanelApiService settings = Settings() - subscription_service = SubscriptionService(settings) + panel_service = PanelApiService(settings) + subscription_service = SubscriptionService(settings, panel_service) await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) except Exception as e: @@ -628,7 +630,9 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte )) # Show user card again - subscription_service = SubscriptionService(settings) + from bot.services.panel_api_service import PanelApiService + panel_service = PanelApiService(settings) + subscription_service = SubscriptionService(settings, panel_service) user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang) keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 21ee390..3b8af20 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -10,9 +10,7 @@ class AdminStates(StatesGroup): waiting_for_promo_bonus_days = State() waiting_for_promo_max_activations = State() waiting_for_promo_validity_days = State() - waiting_for_promo_description = State() waiting_for_promo_edit_details = State() - waiting_for_bulk_promo_details = State() waiting_for_bulk_promo_quantity = State() waiting_for_bulk_promo_bonus_days = State() waiting_for_bulk_promo_max_activations = State() diff --git a/locales/en.json b/locales/en.json index f02392b..0705c2d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -344,11 +344,10 @@ "admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...", - "admin_promo_step1_code": "🎟 Create Promo Code\n\nStep 1 of 5: Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", - "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 5: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", - "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 5: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", - "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 5: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", - "admin_promo_step5_description": "🎟 Create Promo Code\n\nStep 5 of 5: Description (optional)\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\nValid until: {validity}\n\nEnter promo code description or click \"Skip\":", + "admin_promo_step1_code": "🎟 Create Promo Code\n\nStep 1 of 4: Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", + "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", + "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", + "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", "admin_promo_invalid_code_format": "❌ Promo code must contain 3-30 characters (letters and numbers only)", "admin_promo_code_already_exists": "❌ A promo code with this code already exists", @@ -359,6 +358,9 @@ "admin_promo_skip_description": "⏭️ Skip", "admin_promo_description_too_long": "❌ Description must not exceed 200 characters", "admin_promo_creating": "⏳ Creating promo code...", - "admin_promo_created_success": "✅ Promo code created!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}\n📝 Description: {description}", - "admin_promo_creation_failed": "❌ Error creating promo code: {error}" + "admin_promo_created_success": "✅ Promo code created!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", + "admin_promo_creation_failed": "❌ Error creating promo code: {error}", + + "admin_panel_back_button": "⬅️ Back", + "admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}" } diff --git a/locales/ru.json b/locales/ru.json index a0c6bbf..15211e8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -344,11 +344,10 @@ "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", - "admin_promo_step1_code": "🎟 Создание промокода\n\nШаг 1 из 5: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", - "admin_promo_step2_bonus_days": "🎟 Создание промокода\n\nШаг 2 из 5: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", - "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 5: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", - "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 5: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", - "admin_promo_step5_description": "🎟 Создание промокода\n\nШаг 5 из 5: Описание (опционально)\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\nДействует: {validity}\n\nВведите описание промокода или нажмите \"Пропустить\":", + "admin_promo_step1_code": "🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", + "admin_promo_step2_bonus_days": "🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", "admin_promo_invalid_code_format": "❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", @@ -359,6 +358,9 @@ "admin_promo_skip_description": "⏭️ Пропустить", "admin_promo_description_too_long": "❌ Описание не должно превышать 200 символов", "admin_promo_creating": "⏳ Создаю промокод...", - "admin_promo_created_success": "✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}\n📝 Описание: {description}", - "admin_promo_creation_failed": "❌ Ошибка создания промокода: {error}" + "admin_promo_created_success": "✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + "admin_promo_creation_failed": "❌ Ошибка создания промокода: {error}", + + "admin_panel_back_button": "⬅️ Назад", + "admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}" } From aa976181656fd0506505c8cc3b015d410c4606a1 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 18:58:50 +0300 Subject: [PATCH 07/26] Refactor notification service and enhance user management features - Replaced legacy payment notification functions with a new NotificationService for improved code organization and maintainability. - Updated user management handlers to utilize the new notification system for payment confirmations. - Enhanced user card formatting with localized labels for better clarity and user experience. - Introduced context management for the PanelApiService to ensure proper session handling. - Updated localization files to include new labels and messages for user management and notifications. --- bot/handlers/admin/promo_codes.py | 3 +- bot/handlers/admin/statistics.py | 95 +++++++++++++-------------- bot/handlers/admin/user_management.py | 69 +++++++++---------- bot/handlers/user/payment.py | 12 +--- bot/handlers/user/start.py | 1 - bot/services/crypto_pay_service.py | 25 ++++--- bot/services/notification_service.py | 15 +---- bot/services/panel_api_service.py | 10 ++- bot/services/stars_service.py | 25 ++++--- bot/services/tribute_service.py | 25 ++++--- locales/en.json | 40 ++++++++++- locales/ru.json | 40 ++++++++++- 12 files changed, 215 insertions(+), 145 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index b230ec8..a22cc7d 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -266,7 +266,8 @@ async def process_promo_set_validity(callback: types.CallbackQuery, async def process_promo_validity_days_handler(message: types.Message, state: FSMContext, i18n_data: dict, - settings: Settings): + settings: Settings, + session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n: diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index d7c1af4..cfe3279 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -142,56 +142,53 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") try: - panel_service = PanelApiService(settings) - - # Get panel system statistics - logging.info("Fetching panel statistics...") - panel_stats = await panel_service.get_panel_statistics() - nodes_stats = await panel_service.get_nodes_statistics() - online_count = await panel_service.get_online_users_count() - users_activity = await panel_service.get_users_activity_stats() - - logging.info(f"Panel stats response: panel_stats={panel_stats}, nodes_stats={nodes_stats}, online_count={online_count}, users_activity={users_activity}") - - # Online users - if online_count is not None: - stats_text_parts.append(f"🟢 Онлайн сейчас: {online_count}") - else: - stats_text_parts.append(f"🟢 Онлайн сейчас: N/A") - - # Users activity - if users_activity: - today_connected = users_activity.get('today_connected', 'N/A') - week_connected = users_activity.get('week_connected', 'N/A') - never_connected = users_activity.get('never_connected', 'N/A') - stats_text_parts.append(f"📅 Подключались сегодня: {today_connected}") - stats_text_parts.append(f"📅 Подключались за неделю: {week_connected}") - stats_text_parts.append(f"❌ Никогда не подключались: {never_connected}") - else: - stats_text_parts.append(f"📅 Активность пользователей: N/A") - - # Nodes statistics - if nodes_stats: - active_nodes = len([node for node in nodes_stats if node.get('status') == 'active']) - total_nodes = len(nodes_stats) - stats_text_parts.append(f"🔗 Активных нод: {active_nodes}/{total_nodes}") - else: - stats_text_parts.append(f"🔗 Ноды: N/A") - - # System statistics - if panel_stats: - system_info = panel_stats.get('system', {}) - if system_info: - memory_usage = system_info.get('memory_usage_percent', 'N/A') - cpu_usage = system_info.get('cpu_usage_percent', 'N/A') - stats_text_parts.append(f"💾 Использование RAM: {memory_usage}%") - stats_text_parts.append(f"🔄 Загрузка CPU: {cpu_usage}%") + async with PanelApiService(settings) as panel_service: + # Get panel system statistics + logging.info("Fetching panel statistics...") + panel_stats = await panel_service.get_panel_statistics() + nodes_stats = await panel_service.get_nodes_statistics() + online_count = await panel_service.get_online_users_count() + users_activity = await panel_service.get_users_activity_stats() + + logging.info(f"Panel stats response: panel_stats={panel_stats}, nodes_stats={nodes_stats}, online_count={online_count}, users_activity={users_activity}") + + # Online users + if online_count is not None: + stats_text_parts.append(f"🟢 Онлайн сейчас: {online_count}") else: - stats_text_parts.append(f"💾 Системная информация: N/A") - else: - stats_text_parts.append(f"💾 Системная статистика: N/A") - - await panel_service.close() + stats_text_parts.append(f"🟢 Онлайн сейчас: N/A") + + # Users activity + if users_activity: + today_connected = users_activity.get('today_connected', 'N/A') + week_connected = users_activity.get('week_connected', 'N/A') + never_connected = users_activity.get('never_connected', 'N/A') + stats_text_parts.append(f"📅 Подключались сегодня: {today_connected}") + stats_text_parts.append(f"📅 Подключались за неделю: {week_connected}") + stats_text_parts.append(f"❌ Никогда не подключались: {never_connected}") + else: + stats_text_parts.append(f"📅 Активность пользователей: N/A") + + # Nodes statistics + if nodes_stats: + active_nodes = len([node for node in nodes_stats if node.get('status') == 'active']) + total_nodes = len(nodes_stats) + stats_text_parts.append(f"🔗 Активных нод: {active_nodes}/{total_nodes}") + else: + stats_text_parts.append(f"🔗 Ноды: N/A") + + # System statistics + if panel_stats: + system_info = panel_stats.get('system', {}) + if system_info: + memory_usage = system_info.get('memory_usage_percent', 'N/A') + cpu_usage = system_info.get('cpu_usage_percent', 'N/A') + stats_text_parts.append(f"💾 Использование RAM: {memory_usage}%") + stats_text_parts.append(f"🔄 Загрузка CPU: {cpu_usage}%") + else: + stats_text_parts.append(f"💾 Системная информация: N/A") + else: + stats_text_parts.append(f"💾 Системная статистика: N/A") except Exception as e: logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 65daa9d..747c8e7 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -113,27 +113,28 @@ async def format_user_card(user: User, session: AsyncSession, card_parts.append(f"👤 {_('admin_user_card_title', default='Карточка пользователя')}\n") # User details - user_name = user.first_name or "N/A" - username_display = f"@{user.username}" if user.username else "N/A" - registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else "N/A" + na_value = _("admin_user_na_value", default="N/A") + user_name = user.first_name or na_value + username_display = f"@{user.username}" if user.username else na_value + registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value - card_parts.append(f"🆔 ID: {hcode(str(user.user_id))}") - card_parts.append(f"👤 Имя: {hcode(user_name)}") - card_parts.append(f"📱 Username: {hcode(username_display)}") - card_parts.append(f"🌍 Язык: {hcode(user.language_code or 'N/A')}") - card_parts.append(f"📅 Регистрация: {hcode(registration_date)}") + card_parts.append(f"{_('admin_user_id_label', default='🆔 ID:')} {hcode(str(user.user_id))}") + card_parts.append(f"{_('admin_user_name_label', default='👤 Имя:')} {hcode(user_name)}") + card_parts.append(f"{_('admin_user_username_label', default='📱 Username:')} {hcode(username_display)}") + card_parts.append(f"{_('admin_user_language_label', default='🌍 Язык:')} {hcode(user.language_code or na_value)}") + card_parts.append(f"{_('admin_user_registration_label', default='📅 Регистрация:')} {hcode(registration_date)}") # Ban status - ban_status = "🚫 Заблокирован" if user.is_banned else "✅ Активен" - card_parts.append(f"🛡 Статус: {ban_status}") + ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен") + card_parts.append(f"{_('admin_user_status_label', default='🛡 Статус:')} {ban_status}") # Referral info if user.referred_by_id: - card_parts.append(f"🎁 Привлечен по реферальной программе от: {hcode(str(user.referred_by_id))}") + card_parts.append(f"{_('admin_user_referral_label', default='🎁 Привлечен по реферальной программе от:')} {hcode(str(user.referred_by_id))}") # Panel info if user.panel_user_uuid: - card_parts.append(f"🔗 Panel UUID: {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") + card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 Panel UUID:')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") card_parts.append("") # Empty line @@ -146,33 +147,33 @@ async def format_user_card(user: User, session: AsyncSession, end_date = subscription_details.get('end_date') if end_date: end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date) - card_parts.append(f"⏰ Действует до: {hcode(end_date_str)}") + card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ Действует до:')} {hcode(end_date_str)}") status = subscription_details.get('status_from_panel', 'UNKNOWN') - card_parts.append(f"📊 Статус на панели: {hcode(status)}") + card_parts.append(f"{_('admin_user_panel_status_label', default='📊 Статус на панели:')} {hcode(status)}") traffic_limit = subscription_details.get('traffic_limit_bytes') traffic_used = subscription_details.get('traffic_used_bytes') if traffic_limit and traffic_used is not None: traffic_limit_gb = traffic_limit / (1024**3) traffic_used_gb = traffic_used / (1024**3) - card_parts.append(f"📊 Трафик: {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") + card_parts.append(f"{_('admin_user_traffic_label', default='📊 Трафик:')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") else: - card_parts.append(f"💳 Подписка: {hcode('Отсутствует')}") + card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}") except Exception as e: logging.error(f"Error getting subscription details for user {user.user_id}: {e}") - card_parts.append(f"💳 Подписка: {hcode('Ошибка загрузки')}") + card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}") # Statistics try: # Count user logs logs_count = await message_log_dal.count_user_message_logs(session, user.user_id) - card_parts.append(f"📜 Всего действий: {hcode(str(logs_count))}") + card_parts.append(f"{_('admin_user_actions_count_label', default='📜 Всего действий:')} {hcode(str(logs_count))}") # Check if user had any subscriptions had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id) - trial_status = "Использовал" if had_subscriptions else "Не использовал" - card_parts.append(f"🆓 Триал: {hcode(trial_status)}") + trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал") + card_parts.append(f"{_('admin_user_trial_label', default='🏡 Триал:')} {hcode(trial_status)}") except Exception as e: logging.error(f"Error getting user statistics for {user.user_id}: {e}") @@ -353,7 +354,7 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, await session.commit() - status_text = "заблокирован" if new_ban_status else "разблокирован" + status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован") await callback.answer(_( "admin_user_ban_toggle_success", default="✅ Пользователь {status}", @@ -365,9 +366,9 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, from config.settings import Settings from bot.services.panel_api_service import PanelApiService settings = Settings() - panel_service = PanelApiService(settings) - subscription_service = SubscriptionService(settings, panel_service) - await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) + async with PanelApiService(settings) as panel_service: + subscription_service = SubscriptionService(settings, panel_service) + await handle_refresh_user_card(callback, user, subscription_service, session, i18n_instance, lang) except Exception as e: logging.error(f"Error toggling ban for user {user.user_id}: {e}") @@ -631,16 +632,16 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte # Show user card again from bot.services.panel_api_service import PanelApiService - panel_service = PanelApiService(settings) - subscription_service = SubscriptionService(settings, panel_service) - user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang) - keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) - - await message.answer( - user_card_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) + async with PanelApiService(settings) as panel_service: + subscription_service = SubscriptionService(settings, panel_service) + user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang) + keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang) + + await message.answer( + user_card_text, + reply_markup=keyboard.as_markup(), + parse_mode="HTML" + ) except Exception as e: logging.error(f"Error sending direct message to user {target_user_id}: {e}") diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index bc54a9c..a8c2463 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -20,7 +20,7 @@ from bot.services.panel_api_service import PanelApiService from bot.services.yookassa_service import YooKassaService from bot.middlewares.i18n import JsonI18n from config.settings import Settings -from bot.services.notification_service import notify_admin_new_payment, NotificationService +from bot.services.notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard payment_processing_lock = asyncio.Lock() @@ -209,16 +209,6 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, ) except Exception as e: logging.error(f"Failed to send payment notification: {e}") - - # Legacy notification for backwards compatibility - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - subscription_months, - payment_value, - ) except Exception as e_process: logging.error( diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 996e7de..ab9c19e 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -237,7 +237,6 @@ async def start_command_handler(message: types.Message, config_link = active.get("config_link") if active else None config_link = config_link or _("config_link_not_available") - from datetime import datetime new_end_date = result if isinstance(result, datetime) else None promo_success_text = _( diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index b2a40a9..8c67195 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -14,7 +14,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from bot.services.notification_service import notify_admin_new_payment +from bot.services.notification_service import NotificationService from db.dal import payment_dal, user_dal @@ -194,15 +194,20 @@ class CryptoPayService: except Exception as e: logging.error(f"Failed to send CryptoPay success message: {e}") - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - months, - float(invoice.amount), - currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, - ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=float(invoice.amount), + currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, + months=months, + payment_provider="crypto_pay", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send crypto_pay payment notification: {e}") async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured or not self.client: diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index a8166dc..ee9e963 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -220,20 +220,7 @@ async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n, await notification_service.notify_trial_activation(user_id, end_date) -async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n, - user_id: int, months: int, amount: float, - currency: str | None = None) -> None: - currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL - await notify_admins( - bot, - settings, - i18n, - "admin_new_payment_notification", - user_id=user_id, - months=months, - amount=f"{amount:.2f}", - currency=currency_symbol, - ) + async def notify_admin_promo_activation(bot: Bot, settings: Settings, diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index a09f499..582ad55 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -21,6 +21,14 @@ class PanelApiService: self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None self.default_client_ip = "127.0.0.1" + + async def __aenter__(self): + """Context manager entry""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - automatically close session""" + await self.close_session() async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -32,7 +40,7 @@ class PanelApiService: if self._session and not self._session.closed: await self._session.close() self._session = None - logging.info("Panel API service HTTP session closed.") + logging.debug("Panel API service HTTP session closed.") async def close(self): """Alias for close_session for API consistency.""" diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 524547b..548babe 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -10,7 +10,7 @@ from db.dal import payment_dal, user_dal from .subscription_service import SubscriptionService from .referral_service import ReferralService from bot.middlewares.i18n import JsonI18n -from .notification_service import notify_admin_new_payment +from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard @@ -153,13 +153,18 @@ class StarsService: logging.error( f"Failed to send stars payment success message: {e_send}") - await notify_admin_new_payment( - self.bot, - self.settings, - self.i18n, - message.from_user.id, - months, - float(stars_amount), - currency="XTR", - ) + # Send notification about payment + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + user = await user_dal.get_user_by_id(session, message.from_user.id) + await notification_service.notify_payment_received( + user_id=message.from_user.id, + amount=float(stars_amount), + currency="XTR", + months=months, + payment_provider="stars", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index c9d6882..f310f2d 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -13,7 +13,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService -from .notification_service import notify_admin_new_payment +from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from db.dal import payment_dal, user_dal, subscription_dal @@ -187,15 +187,20 @@ class TributeService: logging.error( f"Failed to send Tribute payment success message to user {user_id}: {e}") - await notify_admin_new_payment( - bot, - settings, - i18n, - user_id, - months, - float(price_rub), - currency="RUB", - ) + # Send notification about payment + try: + notification_service = NotificationService(bot, settings, i18n) + user = await user_dal.get_user_by_id(session, user_id) + await notification_service.notify_payment_received( + user_id=user_id, + amount=float(price_rub), + currency="RUB", + months=months, + payment_provider="tribute", + username=user.username if user else None + ) + except Exception as e: + logging.error(f"Failed to send tribute payment notification: {e}") else: await session.commit() return web.Response(status=200, text="ok") diff --git a/locales/en.json b/locales/en.json index 0705c2d..612a39c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -234,7 +234,7 @@ "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", "admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.", - "admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.", + "admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).", "error_unknown": "An unknown error occurred.", @@ -362,5 +362,41 @@ "admin_promo_creation_failed": "❌ Error creating promo code: {error}", "admin_panel_back_button": "⬅️ Back", - "admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}" + "admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}", + + "admin_user_id_label": "🆔 ID:", + "admin_user_name_label": "👤 Name:", + "admin_user_username_label": "📱 Username:", + "admin_user_language_label": "🌍 Language:", + "admin_user_registration_label": "📅 Registration:", + "admin_user_status_label": "🛡 Status:", + "admin_user_referral_label": "🎁 Referred by:", + "admin_user_panel_uuid_label": "🔗 Panel UUID:", + "admin_user_panel_status_label": "📊 Panel Status:", + "admin_user_traffic_label": "📊 Traffic:", + "admin_user_subscription_label": "💼 Subscription:", + "admin_user_trial_label": "🏡 Trial:", + + "admin_user_status_banned": "🚫 Banned", + "admin_user_status_active": "✅ Active", + "admin_user_trial_used": "Used", + "admin_user_trial_not_used": "Not used", + "admin_user_ban_action_banned": "banned", + "admin_user_ban_action_unbanned": "unbanned", + "admin_user_na_value": "N/A", + + "admin_user_subscription_active": "Active until {end_date}", + "admin_user_subscription_expired": "Expired {end_date}", + "admin_user_subscription_none": "No active subscription", + + "admin_user_logs_title": "📋 User logs {user_id}", + "admin_user_logs_empty": "No logs available", + "admin_user_logs_count": "Total entries: {count}", + "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", + + "admin_user_actions_count_label": "📜 Total actions:", + "admin_user_subscription_active_until": "⏰ Active until:", + "admin_user_subscription_error": "Loading error", + + "admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code" } diff --git a/locales/ru.json b/locales/ru.json index 15211e8..70e4bc8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -234,7 +234,7 @@ "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", "admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.", - "admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.", + "admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)", "error_unknown": "Произошла неизвестная ошибка.", @@ -362,5 +362,41 @@ "admin_promo_creation_failed": "❌ Ошибка создания промокода: {error}", "admin_panel_back_button": "⬅️ Назад", - "admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}" + "admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}", + + "admin_user_id_label": "🆔 ID:", + "admin_user_name_label": "👤 Имя:", + "admin_user_username_label": "📱 Username:", + "admin_user_language_label": "🌍 Язык:", + "admin_user_registration_label": "📅 Регистрация:", + "admin_user_status_label": "🛡 Статус:", + "admin_user_referral_label": "🎁 Привлечен по реферальной программе от:", + "admin_user_panel_uuid_label": "🔗 Panel UUID:", + "admin_user_panel_status_label": "📊 Статус на панели:", + "admin_user_traffic_label": "📊 Трафик:", + "admin_user_subscription_label": "💼 Подписка:", + "admin_user_trial_label": "🏡 Триал:", + + "admin_user_status_banned": "🚫 Заблокирован", + "admin_user_status_active": "✅ Активен", + "admin_user_trial_used": "Использовал", + "admin_user_trial_not_used": "Не использовал", + "admin_user_ban_action_banned": "заблокирован", + "admin_user_ban_action_unbanned": "разблокирован", + "admin_user_na_value": "N/A", + + "admin_user_subscription_active": "Активна до {end_date}", + "admin_user_subscription_expired": "Истекла {end_date}", + "admin_user_subscription_none": "Нет активной подписки", + + "admin_user_logs_title": "📋 Логи пользователя {user_id}", + "admin_user_logs_empty": "Логи отсутствуют", + "admin_user_logs_count": "Всего записей: {count}", + "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", + + "admin_user_actions_count_label": "📜 Всего действий:", + "admin_user_subscription_active_until": "⏰ Действует до:", + "admin_user_subscription_error": "Ошибка загрузки", + + "admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод" } From a5b408d20448821db2bbd955bd0dac874ffdfd69 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 21:09:03 +0300 Subject: [PATCH 08/26] Enhance promo code management and user statistics features - Introduced a unified promo code management system, allowing admins to view and manage both active and inactive promo codes with detailed information. - Updated the promo code detail view to include status indicators and management options for editing, toggling status, and viewing activation history. - Enhanced user statistics display by restructuring the information layout and adding new labels for clarity. - Improved localization files to support new messages and labels related to promo code management and user statistics. - Implemented error handling for suspicious promo code attempts, integrating a notification system for better admin awareness. --- bot/handlers/admin/promo_codes.py | 166 +++++++++++++++++++++--- bot/handlers/admin/statistics.py | 100 +++++++++++--- bot/handlers/inline_mode.py | 24 ++-- bot/handlers/user/promo_user.py | 35 ++--- bot/keyboards/inline/admin_keyboards.py | 8 +- bot/services/panel_api_service.py | 40 ++---- bot/services/promo_code_service.py | 11 +- config/settings.py | 1 + db/dal/promo_code_dal.py | 27 ++++ locales/en.json | 69 +++++++++- locales/ru.json | 69 +++++++++- 11 files changed, 442 insertions(+), 108 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index a22cc7d..e8e9403 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -465,10 +465,12 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, await callback.answer() -@router.callback_query(F.data == "admin_action:manage_promos") -async def manage_promo_codes_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): +# New unified promo management system +@router.callback_query(F.data == "admin_action:promo_management") +async def promo_management_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Show list of all promo codes for management""" current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") if not i18n or not callback.message: @@ -476,36 +478,168 @@ async def manage_promo_codes_handler(callback: types.CallbackQuery, return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - promo_models = await promo_code_dal.get_all_active_promo_codes(session, - limit=20, - offset=0) + # Get ALL promo codes (including inactive) + promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) if not promo_models: await callback.message.edit_text( - _("admin_no_active_promos"), + _("admin_promo_management_empty", default="📭 Промокоды отсутствуют"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) await callback.answer() return kb = InlineKeyboardBuilder() for promo in promo_models: + # Show promo code with status indicator + status_emoji = "✅" if promo.is_active else "🚫" + if promo.valid_until and promo.valid_until < datetime.now(timezone.utc): + status_emoji = "⏰" # Expired + elif promo.current_activations >= promo.max_activations: + status_emoji = "🔄" # Used up + + button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})" kb.row( InlineKeyboardButton( - text=promo.code, - callback_data=f"promo_edit:{promo.promo_code_id}"), - InlineKeyboardButton( - text=_("admin_promo_delete_button"), - callback_data=f"promo_delete:{promo.promo_code_id}"), + text=button_text, + callback_data=f"promo_detail:{promo.promo_code_id}") ) + kb.row( - InlineKeyboardButton(text=_("back_to_admin_panel_button"), + InlineKeyboardButton(text=_("back_to_admin_panel_button", default="⬅️ Назад"), callback_data="admin_action:main")) await callback.message.edit_text( - _("admin_manage_promos_title"), - reply_markup=kb.as_markup()) + _("admin_promo_management_title", default="🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:"), + reply_markup=kb.as_markup(), + parse_mode="HTML") await callback.answer() +@router.callback_query(F.data.startswith("promo_detail:")) +async def promo_detail_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Show detailed promo code information with management options""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error displaying promo details.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + if not promo: + await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) + return + + # Determine status + status = _("admin_promo_status_active", default="✅ Активен") + if not promo.is_active: + status = _("admin_promo_status_inactive", default="🚫 Неактивен") + elif promo.valid_until and promo.valid_until < datetime.now(timezone.utc): + status = _("admin_promo_status_expired", default="⏰ Истек") + elif promo.current_activations >= promo.max_activations: + status = _("admin_promo_status_used_up", default="🔄 Исчерпан") + + # Format validity + validity = _("admin_promo_valid_indefinitely", default="Неограниченно") + if promo.valid_until: + validity = promo.valid_until.strftime('%Y-%m-%d %H:%M') + + # Format created date + created_date = promo.created_at.strftime('%Y-%m-%d %H:%M') if promo.created_at else "N/A" + creator = f"Admin {promo.created_by_admin_id}" if promo.created_by_admin_id else "N/A" + + # Build card text + card_text = _( + "admin_promo_card_title", + default="🎟 Промокод: {code}", + code=promo.code + ) + "\n\n" + + card_text += _( + "admin_promo_card_bonus_days", + default="🎁 Бонусные дни: {days}", + days=promo.bonus_days + ) + "\n" + + card_text += _( + "admin_promo_card_activations", + default="🔢 Активации: {current}/{max}", + current=promo.current_activations, + max=promo.max_activations + ) + "\n" + + card_text += _( + "admin_promo_card_validity", + default="⏰ Действует до: {validity}", + validity=validity + ) + "\n" + + card_text += _( + "admin_promo_card_status", + default="📊 Статус: {status}", + status=status + ) + "\n" + + card_text += _( + "admin_promo_card_created", + default="📅 Создан: {created}", + created=created_date + ) + "\n" + + card_text += _( + "admin_promo_card_created_by", + default="👤 Создал: {creator}", + creator=creator + ) + + # Build keyboard + kb = InlineKeyboardBuilder() + + # Row 1: Edit and Toggle status + kb.row( + InlineKeyboardButton( + text=_("admin_promo_edit_button", default="✏️ Редактировать"), + callback_data=f"promo_edit:{promo_id}"), + InlineKeyboardButton( + text=_("admin_promo_toggle_status_button", default="🔄 Вкл/Выкл"), + callback_data=f"promo_toggle:{promo_id}") + ) + + # Row 2: View activations and Delete + kb.row( + InlineKeyboardButton( + text=_("admin_promo_view_activations_button", default="📋 Активации"), + callback_data=f"promo_activations:{promo_id}"), + InlineKeyboardButton( + text=_("admin_promo_delete_button", default="🗑 Удалить"), + callback_data=f"promo_delete:{promo_id}") + ) + + # Row 3: Back to list + kb.row( + InlineKeyboardButton( + text=_("admin_promo_back_to_list_button", default="⬅️ К списку"), + callback_data="admin_action:promo_management") + ) + + await callback.message.edit_text( + card_text, + reply_markup=kb.as_markup(), + parse_mode="HTML") + await callback.answer() + + +# Legacy manage_promo_codes_handler - keeping for compatibility +@router.callback_query(F.data == "admin_action:manage_promos") +async def manage_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + # Redirect to new unified handler + await promo_management_handler(callback, i18n_data, settings, session) + + @router.callback_query(F.data.startswith("promo_edit:")) async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings, diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index cfe3279..485dda1 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -37,27 +37,97 @@ async def show_statistics_handler(callback: types.CallbackQuery, f"\n👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}" ) stats_text_parts.append( - f"📊 Всего: {user_stats['total_users']}" + f"📊 {_('admin_user_stats_total_label', default='Всего')}: {user_stats['total_users']}" + ) + # Removed: Active today moved to panel stats + stats_text_parts.append( + f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: {user_stats['paid_subscriptions']}" ) stats_text_parts.append( - f"📈 Активных сегодня: {user_stats['active_today']}" + f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: {user_stats['trial_users']}" ) stats_text_parts.append( - f"💳 С платной подпиской: {user_stats['paid_subscriptions']}" + f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: {user_stats['inactive_users']}" ) stats_text_parts.append( - f"🆓 На пробном периоде: {user_stats['trial_users']}" + f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: {user_stats['banned_users']}" ) stats_text_parts.append( - f"😴 Неактивных: {user_stats['inactive_users']}" - ) - stats_text_parts.append( - f"🚫 Заблокированных: {user_stats['banned_users']}" - ) - stats_text_parts.append( - f"🎁 Привлечено по реферальной программе: {user_stats['referral_users']}" + f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: {user_stats['referral_users']}" ) + # Panel Statistics - moved above financial + stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + + try: + async with PanelApiService(settings) as panel_service: + # Get system stats + system_stats = await panel_service.get_system_stats() + bandwidth_stats = await panel_service.get_bandwidth_stats() + nodes_stats = await panel_service.get_nodes_statistics() + + logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}") + + if system_stats: + users = system_stats.get('users', {}) + active_users = users.get('active', 0) + disabled_users = users.get('disabled', 0) + expired_users = users.get('expired', 0) + limited_users = users.get('limited', 0) + total_users = users.get('total', 0) + + stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: {active_users}") + stats_text_parts.append(f"🔴 {_('admin_panel_offline_label', default='Офлайн')}: {disabled_users}") + stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: {expired_users}") + stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: {limited_users}") + stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: {total_users}") + + # System resources + cpu = system_stats.get('cpu', {}) + memory = system_stats.get('memory', {}) + if cpu: + cpu_usage = cpu.get('usage', 0) + stats_text_parts.append(f"🔄 {_('admin_panel_cpu_usage_label', default='Загрузка CPU')}: {cpu_usage:.1f}%") + if memory: + memory_usage = memory.get('usage', 0) + stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: {memory_usage:.1f}%") + else: + stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}") + + # Bandwidth stats + if bandwidth_stats: + today_traffic = bandwidth_stats.get('bandwidthToday', {}) + week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {}) + month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {}) + + if today_traffic: + today_total = today_traffic.get('total', '0 B') + stats_text_parts.append(f"📊 {_('admin_panel_traffic_today_label', default='Трафик сегодня')}: {today_total}") + + if week_traffic: + week_total = week_traffic.get('total', '0 B') + stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: {week_total}") + + if month_traffic: + month_total = month_traffic.get('total', '0 B') + stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: {month_total}") + else: + stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}") + + # Nodes stats + if nodes_stats: + last_seven_days = nodes_stats.get('lastSevenDays', []) + active_nodes_count = len([node for node in last_seven_days if node.get('status') == 'active']) + total_nodes_count = len(last_seven_days) + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {active_nodes_count}/{total_nodes_count}") + else: + stats_text_parts.append(f"⚠️ {_('admin_panel_nodes_stats_error', default='Ошибка получения статистики нод')}") + + except Exception as e: + logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) + stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}") + stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}") + # Financial statistics financial_stats = await payment_dal.get_financial_statistics(session) @@ -65,16 +135,16 @@ async def show_statistics_handler(callback: types.CallbackQuery, f"\n💰 {_('admin_financial_stats_header', default='Финансовая статистика')}" ) stats_text_parts.append( - f"📅 За сегодня: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} платежей)" + f"📅 {_('admin_financial_today_label', default='За сегодня')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})" ) stats_text_parts.append( - f"📅 За неделю: {financial_stats['week_revenue']:.2f} RUB" + f"📅 {_('admin_financial_week_label', default='За неделю')}: {financial_stats['week_revenue']:.2f} RUB" ) stats_text_parts.append( - f"📅 За месяц: {financial_stats['month_revenue']:.2f} RUB" + f"📅 {_('admin_financial_month_label', default='За месяц')}: {financial_stats['month_revenue']:.2f} RUB" ) stats_text_parts.append( - f"🏆 За все время: {financial_stats['all_time_revenue']:.2f} RUB" + f"🏆 {_('admin_financial_all_time_label', default='За все время')}: {financial_stats['all_time_revenue']:.2f} RUB" ) last_payments_models: List[ diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 693ea7a..7634414 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -92,8 +92,7 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot, "✨ Быстрый и надежный\n" "🔒 Полная анонимность\n" "🌍 Серверы по всему миру\n" - "💎 Бесплатный пробный период\n\n" - "Переходи по ссылке: {referral_link}", + "💎 Бесплатный пробный период\n\n{referral_link}", referral_link=referral_link ) @@ -180,8 +179,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s default="👥 Статистика пользователей" ), description=_( - "inline_admin_user_stats_desc", - default=f"Всего: {user_stats['total_users']}, Активных: {user_stats['paid_subscriptions']}" + "inline_stats_description", + default="Всего: {total}, Активных: {active}", + total=user_stats['total_users'], + active=user_stats['paid_subscriptions'] ), input_message_content=InputTextMessageContent( message_text=stats_text, @@ -225,8 +226,9 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la default="💰 Финансовая статистика" ), description=_( - "inline_admin_financial_stats_desc", - default=f"Сегодня: {financial_stats['today_revenue']:.2f} RUB" + "inline_financial_description", + default="Сегодня: {today} RUB", + today=f"{financial_stats['today_revenue']:.2f}" ), input_message_content=InputTextMessageContent( message_text=stats_text, @@ -296,8 +298,10 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: default="🖥 Системная статистика" ), description=_( - "inline_admin_system_stats_desc", - default=f"Онлайн: {active_subs}, Офлайн: {max(0, offline_users)}" + "inline_system_description", + default="Онлайн: {online}, Офлайн: {offline}", + online=active_subs, + offline=max(0, offline_users) ), input_message_content=InputTextMessageContent( message_text=stats_text, @@ -327,7 +331,7 @@ async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> Inline "💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!" ) title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)") - description = _("inline_admin_help_desc", default="Доступны команды: реф, стат, финансы, система") + description = _("inline_admin_help_description", default="Доступны команды: реф, стат, финансы, система") else: help_text = _( "inline_user_help_message", @@ -337,7 +341,7 @@ async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> Inline "💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!" ) title = _("inline_user_help_title", default="🤖 Inline помощь") - description = _("inline_user_help_desc", default="Доступна команда: реф (реферальная ссылка)") + description = _("inline_user_help_description", default="Доступна команда: реф (реферальная ссылка)") return InlineQueryResultArticle( id="help", diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index f720324..18fa120 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -105,30 +105,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, response_to_user_text = "" if is_suspicious: - - if settings.ADMIN_IDS: - admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username" - - admin_lang = settings.DEFAULT_LANGUAGE - _admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw) - admin_notification_text = _admin( - admin_notify_key, - user_id=user.id, - user_username=user.username or "N/A", - user_first_name=user.first_name or "N/A", - promo_code_input=hcode(code_input)) - for admin_id in settings.ADMIN_IDS: - try: - await bot.send_message(admin_id, - admin_notification_text, - parse_mode="HTML") - except Exception as e_admin_notify: - logging.error( - f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}" - ) + # Send notification through NotificationService if enabled + if settings.LOG_SUSPICIOUS_ACTIVITY: + try: + from bot.services.notification_service import NotificationService + notification_service = NotificationService(bot, settings, i18n) + await notification_service.notify_suspicious_promo_attempt( + user_id=user.id, + username=user.username, + first_name=user.first_name, + suspicious_input=code_input + ) + except Exception as e: + logging.error(f"Failed to send suspicious promo notification: {e}") response_to_user_text = _("promo_code_not_found", code=hcode(code_input.upper())) + reply_markup = get_back_to_main_menu_markup(current_lang, i18n) else: success, result = await promo_code_service.apply_promo_code( diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 15e7c32..99eb072 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -88,14 +88,12 @@ def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark callback_data="admin_action:create_promo") builder.button(text=_(key="admin_create_bulk_promo_button"), callback_data="admin_action:create_bulk_promo") - builder.button(text=_(key="admin_manage_promos_button"), - callback_data="admin_action:manage_promos") - builder.button(text=_(key="admin_view_promos_button"), - callback_data="admin_action:view_promos") + builder.button(text=_(key="admin_promo_management_button"), + callback_data="admin_action:promo_management") builder.button(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main") - builder.adjust(2, 2, 1) + builder.adjust(2, 1, 1) return builder.as_markup() diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 582ad55..2f036b1 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -470,41 +470,23 @@ class PanelApiService: return await panel_sync_dal.get_panel_sync_status(session) - async def get_panel_statistics(self) -> Optional[Dict[str, Any]]: - """Get general panel statistics""" - response_data = await self._request("GET", "/admin/stats", log_full_response=False) + async def get_system_stats(self) -> Optional[Dict[str, Any]]: + """Get system statistics (CPU, memory, users counts)""" + response_data = await self._request("GET", "/system/stats", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None + async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]: + """Get bandwidth statistics""" + response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False) + if response_data and not response_data.get("error") and "response" in response_data: + return response_data.get("response") + return None - async def get_nodes_statistics(self) -> Optional[List[Dict[str, Any]]]: + async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]: """Get nodes statistics""" - response_data = await self._request("GET", "/admin/nodes/stats", log_full_response=False) - if response_data and not response_data.get("error") and "response" in response_data: - return response_data.get("response", {}).get("nodes", []) - return None - - - async def get_system_info(self) -> Optional[Dict[str, Any]]: - """Get system information""" - response_data = await self._request("GET", "/admin/system/info", log_full_response=False) - if response_data and not response_data.get("error") and "response" in response_data: - return response_data.get("response") - return None - - - async def get_online_users_count(self) -> Optional[int]: - """Get count of currently online users""" - response_data = await self._request("GET", "/admin/stats/online-users", log_full_response=False) - if response_data and not response_data.get("error") and "response" in response_data: - return response_data.get("response", {}).get("count", 0) - return None - - - async def get_users_activity_stats(self) -> Optional[Dict[str, Any]]: - """Get users activity statistics (today, week, never connected)""" - response_data = await self._request("GET", "/admin/stats/users-activity", log_full_response=False) + response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: return response_data.get("response") return None \ No newline at end of file diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index 4338d81..6aaaca8 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -11,7 +11,7 @@ from db.models import PromoCode, User from .subscription_service import SubscriptionService from bot.middlewares.i18n import JsonI18n -from .notification_service import notify_admin_promo_activation, NotificationService +from .notification_service import NotificationService class PromoCodeService: @@ -75,15 +75,6 @@ class PromoCodeService: except Exception as e: logging.error(f"Failed to send promo activation notification: {e}") - # Legacy notification for backwards compatibility - await notify_admin_promo_activation( - self.bot, - self.settings, - self.i18n, - user_id, - code_input_upper, - bonus_days, - ) return True, new_end_date else: diff --git a/config/settings.py b/config/settings.py index 37df041..208a4aa 100644 --- a/config/settings.py +++ b/config/settings.py @@ -306,6 +306,7 @@ class Settings(BaseSettings): LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments") LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations") LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations") + LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts") model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index 591be40..f937b4d 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -56,6 +56,33 @@ async def get_all_active_promo_codes(session: AsyncSession, return result.scalars().all() +async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 50, + offset: int = 0) -> List[PromoCode]: + """Get all promo codes (active and inactive) with pagination for management""" + stmt = (select(PromoCode).order_by( + PromoCode.created_at.desc()).limit(limit).offset(offset)) + result = await session.execute(stmt) + return result.scalars().all() + + +async def get_promo_code_by_id(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: + """Get promo code by ID""" + stmt = select(PromoCode).where(PromoCode.promo_code_id == promo_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> List: + """Get activation history for a specific promo code""" + from db.models import PromoCodeActivation + stmt = (select(PromoCodeActivation) + .where(PromoCodeActivation.promo_code_id == promo_code_id) + .order_by(PromoCodeActivation.created_at.desc()) + .limit(50)) # Limit to last 50 activations + result = await session.execute(stmt) + return result.scalars().all() + + async def update_promo_code(session: AsyncSession, promo_id: int, update_data: Dict[str, Any]) -> Optional[PromoCode]: promo = await get_promo_code_by_id(session, promo_id) diff --git a/locales/en.json b/locales/en.json index 612a39c..50d6ab8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -398,5 +398,72 @@ "admin_user_subscription_active_until": "⏰ Active until:", "admin_user_subscription_error": "Loading error", - "admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code" + "admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code", + + "admin_suspicious_promo_attempt_notification": "⚠️ Suspicious promo code attempt\n\n👤 User: @{user_username} (ID: {user_id})\n📝 Name: {user_first_name}\n💬 Input text: {promo_code_input}", + "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Suspicious promo code attempt\n\n👤 User ID: {user_id}\n📝 Name: {user_first_name}\n💬 Input text: {promo_code_input}", + + "admin_promo_management_button": "🎟 Promo Management", + "admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:", + "admin_promo_management_empty": "📭 No promo codes available", + "admin_promo_card_title": "🎟 Promo Code: {code}", + "admin_promo_card_bonus_days": "🎁 Bonus days: {days}", + "admin_promo_card_activations": "🔢 Activations: {current}/{max}", + "admin_promo_card_validity": "⏰ Valid until: {validity}", + "admin_promo_card_status": "📊 Status: {status}", + "admin_promo_card_created": "📅 Created: {created}", + "admin_promo_card_created_by": "👤 Created by: {creator}", + "admin_promo_status_active": "✅ Active", + "admin_promo_status_inactive": "🚫 Inactive", + "admin_promo_status_expired": "⏰ Expired", + "admin_promo_status_used_up": "🔄 Used up", + "admin_promo_edit_button": "✏️ Edit", + "admin_promo_toggle_status_button": "🔄 On/Off", + "admin_promo_delete_button": "🗑 Delete", + "admin_promo_view_activations_button": "📋 Activations", + "admin_promo_back_to_list_button": "⬅️ Back to list", + + "admin_panel_stats_header": "Panel Statistics", + "admin_panel_online_label": "Online", + "admin_panel_offline_label": "Offline", + "admin_panel_expired_label": "Expired", + "admin_panel_limited_label": "Limited", + "admin_panel_total_users_label": "Total users", + "admin_panel_cpu_usage_label": "CPU Usage", + "admin_panel_memory_usage_label": "RAM Usage", + "admin_panel_traffic_today_label": "Traffic today", + "admin_panel_traffic_week_label": "Traffic this week", + "admin_panel_traffic_month_label": "Traffic this month", + "admin_panel_nodes_label": "Active nodes", + "admin_panel_system_stats_error": "Error getting system statistics", + "admin_panel_bandwidth_stats_error": "Error getting bandwidth statistics", + "admin_panel_nodes_stats_error": "Error getting nodes statistics", + "admin_panel_stats_fetch_error": "Error fetching panel data", + "admin_panel_stats_error_details": "Details", + + "inline_referral_message": "🎁 Get bonuses for friends!\n\nConnect friends to fast VPN and get bonus days for each new user.\n\n💰 Your referral link:\n{referral_link}", + "inline_referral_title": "Referral link", + "inline_referral_description": "Share referral link to get bonuses", + "inline_stats_title": "Bot statistics", + "inline_stats_description": "Total: {total}, Active: {active}", + "inline_financial_title": "Financial statistics", + "inline_financial_description": "Today: {today} RUB", + "inline_system_title": "System statistics", + "inline_system_description": "Online: {online}, Offline: {offline}", + "inline_admin_help_title": "Commands help", + "inline_admin_help_description": "Available commands: ref, stat, financial, system", + "inline_user_help_title": "Commands help", + "inline_user_help_description": "Available command: ref (referral link)", + + "admin_user_stats_total_label": "Total", + "admin_user_stats_paid_subs_label": "With paid subscription", + "admin_user_stats_trial_label": "On trial period", + "admin_user_stats_inactive_label": "Inactive", + "admin_user_stats_banned_label": "Banned", + "admin_user_stats_referral_label": "Attracted via referral program", + "admin_financial_today_label": "Today", + "admin_financial_week_label": "This week", + "admin_financial_month_label": "This month", + "admin_financial_all_time_label": "All time", + "admin_financial_payments_label": "payments" } diff --git a/locales/ru.json b/locales/ru.json index 70e4bc8..86f83ff 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -398,5 +398,72 @@ "admin_user_subscription_active_until": "⏰ Действует до:", "admin_user_subscription_error": "Ошибка загрузки", - "admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод" + "admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод", + + "admin_suspicious_promo_attempt_notification": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь: @{user_username} (ID: {user_id})\n📝 Имя: {user_first_name}\n💬 Введенный текст: {promo_code_input}", + "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь ID: {user_id}\n📝 Имя: {user_first_name}\n💬 Введенный текст: {promo_code_input}", + + "admin_promo_management_button": "🎟 Управление промокодами", + "admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:", + "admin_promo_management_empty": "📭 Промокоды отсутствуют", + "admin_promo_card_title": "🎟 Промокод: {code}", + "admin_promo_card_bonus_days": "🎁 Бонусные дни: {days}", + "admin_promo_card_activations": "🔢 Активации: {current}/{max}", + "admin_promo_card_validity": "⏰ Действует до: {validity}", + "admin_promo_card_status": "📊 Статус: {status}", + "admin_promo_card_created": "📅 Создан: {created}", + "admin_promo_card_created_by": "👤 Создал: {creator}", + "admin_promo_status_active": "✅ Активен", + "admin_promo_status_inactive": "🚫 Неактивен", + "admin_promo_status_expired": "⏰ Истек", + "admin_promo_status_used_up": "🔄 Исчерпан", + "admin_promo_edit_button": "✏️ Редактировать", + "admin_promo_toggle_status_button": "🔄 Вкл/Выкл", + "admin_promo_delete_button": "🗑 Удалить", + "admin_promo_view_activations_button": "📋 Активации", + "admin_promo_back_to_list_button": "⬅️ К списку", + + "admin_panel_stats_header": "Статистика панели", + "admin_panel_online_label": "Онлайн", + "admin_panel_offline_label": "Офлайн", + "admin_panel_expired_label": "Истекшие", + "admin_panel_limited_label": "Ограниченные", + "admin_panel_total_users_label": "Всего пользователей", + "admin_panel_cpu_usage_label": "Загрузка CPU", + "admin_panel_memory_usage_label": "Использование RAM", + "admin_panel_traffic_today_label": "Трафик сегодня", + "admin_panel_traffic_week_label": "Трафик за неделю", + "admin_panel_traffic_month_label": "Трафик за месяц", + "admin_panel_nodes_label": "Активных нод", + "admin_panel_system_stats_error": "Ошибка получения системной статистики", + "admin_panel_bandwidth_stats_error": "Ошибка получения статистики трафика", + "admin_panel_nodes_stats_error": "Ошибка получения статистики нод", + "admin_panel_stats_fetch_error": "Ошибка получения данных с панели", + "admin_panel_stats_error_details": "Детали", + + "inline_referral_message": "🎁 Получи бонусы за друзей!\n\nПодключай друзей к быстрому VPN и получай бонусные дни за каждого нового пользователя.\n\n💰 Твоя реферальная ссылка:\n{referral_link}", + "inline_referral_title": "Реферальная ссылка", + "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", + "inline_stats_title": "Статистика бота", + "inline_stats_description": "Всего: {total}, Активных: {active}", + "inline_financial_title": "Финансовая статистика", + "inline_financial_description": "Сегодня: {today} RUB", + "inline_system_title": "Системная статистика", + "inline_system_description": "Онлайн: {online}, Офлайн: {offline}", + "inline_admin_help_title": "Помощь по командам", + "inline_admin_help_description": "Доступны команды: реф, стат, финансы, система", + "inline_user_help_title": "Помощь по командам", + "inline_user_help_description": "Доступна команда: реф (реферальная ссылка)", + + "admin_user_stats_total_label": "Всего", + "admin_user_stats_paid_subs_label": "С платной подпиской", + "admin_user_stats_trial_label": "На пробном периоде", + "admin_user_stats_inactive_label": "Неактивных", + "admin_user_stats_banned_label": "Заблокированных", + "admin_user_stats_referral_label": "Привлечено по реферальной программе", + "admin_financial_today_label": "За сегодня", + "admin_financial_week_label": "За неделю", + "admin_financial_month_label": "За месяц", + "admin_financial_all_time_label": "За все время", + "admin_financial_payments_label": "платежей" } From ebca408227848b4005597760054137303ca14870 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 21:27:46 +0300 Subject: [PATCH 09/26] Add promo management action to admin panel and remove panel statistics retrieval - Introduced a new action for managing promo codes within the admin panel, enhancing admin capabilities. - Removed the panel statistics retrieval code from the statistics handler to streamline functionality and reduce complexity. - Updated localization files to reflect changes related to promo management. --- bot/handlers/admin/common.py | 4 +++ bot/handlers/admin/statistics.py | 57 -------------------------------- 2 files changed, 4 insertions(+), 57 deletions(-) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index 0592fdb..ec42332 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -105,6 +105,10 @@ async def admin_panel_actions_callback_handler( elif action == "view_logs_menu": await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session) + elif action == "promo_management": + from . import promo_codes as admin_promo_handlers + await admin_promo_handlers.promo_management_handler( + callback, i18n_data, settings, session) elif action == "sync_panel": await admin_sync_handlers.sync_command_handler( diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 485dda1..54622aa 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -208,63 +208,6 @@ async def show_statistics_handler(callback: types.CallbackQuery, else: stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}") - # Panel Statistics - stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") - - try: - async with PanelApiService(settings) as panel_service: - # Get panel system statistics - logging.info("Fetching panel statistics...") - panel_stats = await panel_service.get_panel_statistics() - nodes_stats = await panel_service.get_nodes_statistics() - online_count = await panel_service.get_online_users_count() - users_activity = await panel_service.get_users_activity_stats() - - logging.info(f"Panel stats response: panel_stats={panel_stats}, nodes_stats={nodes_stats}, online_count={online_count}, users_activity={users_activity}") - - # Online users - if online_count is not None: - stats_text_parts.append(f"🟢 Онлайн сейчас: {online_count}") - else: - stats_text_parts.append(f"🟢 Онлайн сейчас: N/A") - - # Users activity - if users_activity: - today_connected = users_activity.get('today_connected', 'N/A') - week_connected = users_activity.get('week_connected', 'N/A') - never_connected = users_activity.get('never_connected', 'N/A') - stats_text_parts.append(f"📅 Подключались сегодня: {today_connected}") - stats_text_parts.append(f"📅 Подключались за неделю: {week_connected}") - stats_text_parts.append(f"❌ Никогда не подключались: {never_connected}") - else: - stats_text_parts.append(f"📅 Активность пользователей: N/A") - - # Nodes statistics - if nodes_stats: - active_nodes = len([node for node in nodes_stats if node.get('status') == 'active']) - total_nodes = len(nodes_stats) - stats_text_parts.append(f"🔗 Активных нод: {active_nodes}/{total_nodes}") - else: - stats_text_parts.append(f"🔗 Ноды: N/A") - - # System statistics - if panel_stats: - system_info = panel_stats.get('system', {}) - if system_info: - memory_usage = system_info.get('memory_usage_percent', 'N/A') - cpu_usage = system_info.get('cpu_usage_percent', 'N/A') - stats_text_parts.append(f"💾 Использование RAM: {memory_usage}%") - stats_text_parts.append(f"🔄 Загрузка CPU: {cpu_usage}%") - else: - stats_text_parts.append(f"💾 Системная информация: N/A") - else: - stats_text_parts.append(f"💾 Системная статистика: N/A") - - except Exception as e: - logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) - stats_text_parts.append("❌ Ошибка получения данных с панели") - stats_text_parts.append(f"⚠️ Детали: {str(e)}") - final_text = "\n".join(stats_text_parts) try: From 23aa3934e4533392d10fcbd08b30f108c42fd5bc Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 21:37:39 +0300 Subject: [PATCH 10/26] Enhance inline query handling and admin statistics retrieval - Updated inline query handler to include settings for thumbnail URLs, improving the customization of inline results. - Refactored admin statistics retrieval to utilize new data structures for user status and bandwidth statistics, enhancing clarity and accuracy in reporting. - Removed deprecated help result creation code to streamline the inline query functionality. - Added new settings for inline mode thumbnail URLs to improve maintainability and flexibility in the codebase. --- bot/handlers/admin/common.py | 1 - bot/handlers/admin/statistics.py | 60 +++++++++++++++------------ bot/handlers/inline_mode.py | 71 +++++++------------------------- config/settings.py | 6 +++ 4 files changed, 55 insertions(+), 83 deletions(-) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index ec42332..f08e342 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -106,7 +106,6 @@ async def admin_panel_actions_callback_handler( await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session) elif action == "promo_management": - from . import promo_codes as admin_promo_handlers await admin_promo_handlers.promo_management_handler( callback, i18n_data, settings, session) elif action == "sync_panel": diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 54622aa..4cdcc73 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -70,58 +70,66 @@ async def show_statistics_handler(callback: types.CallbackQuery, if system_stats: users = system_stats.get('users', {}) - active_users = users.get('active', 0) - disabled_users = users.get('disabled', 0) - expired_users = users.get('expired', 0) - limited_users = users.get('limited', 0) - total_users = users.get('total', 0) + status_counts = users.get('statusCounts', {}) + online_stats = system_stats.get('onlineStats', {}) - stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: {active_users}") - stats_text_parts.append(f"🔴 {_('admin_panel_offline_label', default='Офлайн')}: {disabled_users}") + active_users = status_counts.get('ACTIVE', 0) + disabled_users = status_counts.get('DISABLED', 0) + expired_users = status_counts.get('EXPIRED', 0) + limited_users = status_counts.get('LIMITED', 0) + total_users = users.get('totalUsers', 0) + online_now = online_stats.get('onlineNow', 0) + + stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: {online_now}") + stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: {active_users}") + stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: {disabled_users}") stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: {expired_users}") stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: {limited_users}") stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: {total_users}") # System resources - cpu = system_stats.get('cpu', {}) memory = system_stats.get('memory', {}) - if cpu: - cpu_usage = cpu.get('usage', 0) - stats_text_parts.append(f"🔄 {_('admin_panel_cpu_usage_label', default='Загрузка CPU')}: {cpu_usage:.1f}%") if memory: - memory_usage = memory.get('usage', 0) + memory_total = memory.get('total', 1) + memory_used = memory.get('used', 0) + memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0 stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: {memory_usage:.1f}%") else: stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}") # Bandwidth stats if bandwidth_stats: - today_traffic = bandwidth_stats.get('bandwidthToday', {}) week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {}) - month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {}) - - if today_traffic: - today_total = today_traffic.get('total', '0 B') - stats_text_parts.append(f"📊 {_('admin_panel_traffic_today_label', default='Трафик сегодня')}: {today_total}") + month_traffic = bandwidth_stats.get('bandwidthLast30Days', {}) + # Fallback to the actual key name from API if the above doesn't exist + if not month_traffic: + month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {}) if week_traffic: - week_total = week_traffic.get('total', '0 B') + week_total = week_traffic.get('current', '0 B') stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: {week_total}") if month_traffic: - month_total = month_traffic.get('total', '0 B') + month_total = month_traffic.get('current', '0 B') stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: {month_total}") else: stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}") - # Nodes stats - if nodes_stats: + # Nodes stats + if nodes_stats and 'lastSevenDays' in nodes_stats: last_seven_days = nodes_stats.get('lastSevenDays', []) - active_nodes_count = len([node for node in last_seven_days if node.get('status') == 'active']) - total_nodes_count = len(last_seven_days) - stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {active_nodes_count}/{total_nodes_count}") + # Get unique node names from the data + unique_nodes = set() + for node_data in last_seven_days: + unique_nodes.add(node_data.get('nodeName', '')) + total_nodes_count = len(unique_nodes) + # Assume all nodes are active since we don't have status info + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_nodes_count}/{total_nodes_count}") else: - stats_text_parts.append(f"⚠️ {_('admin_panel_nodes_stats_error', default='Ошибка получения статистики нод')}") + # Use nodes total from system stats as fallback + nodes_info = system_stats.get('nodes', {}) if system_stats else {} + total_online = nodes_info.get('totalOnline', 0) + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_online}") except Exception as e: logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 7634414..2224371 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -38,7 +38,7 @@ async def inline_query_handler(inline_query: InlineQuery, # For all users: referral functionality if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query: referral_result = await create_referral_result( - inline_query, bot, referral_service, i18n, current_lang + inline_query, bot, referral_service, i18n, current_lang, settings ) if referral_result: results.append(referral_result) @@ -46,14 +46,11 @@ async def inline_query_handler(inline_query: InlineQuery, # For admins: statistics if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query): stats_results = await create_admin_stats_results( - session, i18n, current_lang + session, i18n, current_lang, settings ) results.extend(stats_results) - # Show help if no specific query - if not query: - help_result = await create_help_result(i18n, current_lang, is_admin) - results.append(help_result) + # Limit results to 50 (Telegram limit) results = results[:50] @@ -72,7 +69,7 @@ async def inline_query_handler(inline_query: InlineQuery, async def create_referral_result(inline_query: InlineQuery, bot: Bot, referral_service: ReferralService, - i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: + i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: """Create referral link result for inline query""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -110,7 +107,7 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot, message_text=message_text, disable_web_page_preview=True ), - thumbnail_url="https://cdn-icons-png.flaticon.com/512/1077/1077114.png" + thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL ) except Exception as e: @@ -118,24 +115,24 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot, return None -async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str) -> List[InlineQueryResultArticle]: +async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> List[InlineQueryResultArticle]: """Create admin statistics results for inline query""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) results = [] try: # Quick user stats - user_stats_result = await create_user_stats_result(session, i18n_instance, lang) + user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings) if user_stats_result: results.append(user_stats_result) # Quick financial stats - financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang) + financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang, settings) if financial_stats_result: results.append(financial_stats_result) # Quick system stats - system_stats_result = await create_system_stats_result(session, i18n_instance, lang) + system_stats_result = await create_system_stats_result(session, i18n_instance, lang, settings) if system_stats_result: results.append(system_stats_result) @@ -145,7 +142,7 @@ async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: return results -async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: +async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: """Create user statistics result""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -188,7 +185,7 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s message_text=stats_text, parse_mode="HTML" ), - thumbnail_url="https://cdn-icons-png.flaticon.com/512/681/681494.png" + thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL ) except Exception as e: @@ -196,7 +193,7 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s return None -async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: +async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: """Create financial statistics result""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -234,7 +231,7 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la message_text=stats_text, parse_mode="HTML" ), - thumbnail_url="https://cdn-icons-png.flaticon.com/512/2769/2769339.png" + thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL ) except Exception as e: @@ -242,7 +239,7 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la return None -async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str) -> Optional[InlineQueryResultArticle]: +async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: """Create system statistics result with online/offline/expired/limited info""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -307,7 +304,7 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: message_text=stats_text, parse_mode="HTML" ), - thumbnail_url="https://cdn-icons-png.flaticon.com/512/2920/2920277.png" + thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL ) except Exception as e: @@ -315,41 +312,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: return None -async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> InlineQueryResultArticle: - """Create help result explaining inline mode features""" - _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) - - if is_admin: - help_text = _( - "inline_admin_help_message", - default="🤖 Inline режим бота\n\n" - "📱 Доступные команды:\n\n" - "🎁 реф/ref - поделиться реферальной ссылкой\n" - "👥 стат/stat - статистика пользователей\n" - "💰 финансы - финансовая статистика\n" - "🖥 система - системная статистика\n\n" - "💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!" - ) - title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)") - description = _("inline_admin_help_description", default="Доступны команды: реф, стат, финансы, система") - else: - help_text = _( - "inline_user_help_message", - default="🤖 Inline режим бота\n\n" - "📱 Доступные команды:\n\n" - "🎁 реф/ref - поделиться реферальной ссылкой\n\n" - "💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!" - ) - title = _("inline_user_help_title", default="🤖 Inline помощь") - description = _("inline_user_help_description", default="Доступна команда: реф (реферальная ссылка)") - - return InlineQueryResultArticle( - id="help", - title=title, - description=description, - input_message_content=InputTextMessageContent( - message_text=help_text, - parse_mode="HTML" - ), - thumbnail_url="https://cdn-icons-png.flaticon.com/512/906/906794.png" - ) \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 208a4aa..ac6f649 100644 --- a/config/settings.py +++ b/config/settings.py @@ -111,6 +111,12 @@ class Settings(BaseSettings): SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None) START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None) + + # Inline mode thumbnail URLs + INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png") + INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png") + INLINE_FINANCIAL_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2769/2769339.png") + INLINE_SYSTEM_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2920/2920277.png") @computed_field @property From 52c4f83dd11da9e2b5979fa37015b81ac4c780b3 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 21:50:44 +0300 Subject: [PATCH 11/26] Enhance inline and admin statistics features - Updated inline user statistics message to provide clearer information about bot usage and user status. - Refactored system statistics retrieval to utilize the PanelApiService for improved data accuracy and detail, including memory usage and bandwidth statistics. - Simplified the success message for bulk promo code creation by removing the codes list from the message, ensuring clarity. - Enhanced localization files to reflect changes in success messages and added new labels for better user experience. --- bot/handlers/admin/promo_codes.py | 35 ++++--- bot/handlers/inline_mode.py | 153 +++++++++++++++++++----------- locales/en.json | 4 +- locales/ru.json | 4 +- 4 files changed, 122 insertions(+), 74 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index e8e9403..6f2a175 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1140,15 +1140,13 @@ async def create_bulk_promo_codes_final(callback_or_message, await session.commit() # Send success message - codes_text = " ".join(created_codes) success_text = _( "admin_bulk_promo_created_success", - default="✅ Создано {count} промокодов на {days} дней!\n\nДействуют до: {validity}\nМакс. активации: {max_act}\n\nПромокоды:\n{codes}", + default="✅ Создано {count} промокодов на {days} дней!\n\nДействуют до: {validity}\nМакс. активации: {max_act}", count=quantity, days=bonus_days, validity=valid_until_str_display, - max_act=max_activations, - codes=codes_text[:3500] + "..." if len(codes_text) > 3500 else codes_text + max_act=max_activations ) if hasattr(callback_or_message, 'message'): @@ -1162,22 +1160,21 @@ async def create_bulk_promo_codes_final(callback_or_message, parse_mode="HTML" ) - # Send codes as file if too many - if len(codes_text) > 3500: - codes_file_content = "\n".join(created_codes) - codes_file = types.BufferedInputFile( - codes_file_content.encode('utf-8'), - filename=f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - ) - - await target_message.answer_document( - codes_file, - caption=_( - "admin_bulk_promo_codes_file", - default="📄 Все созданные промокоды в файле", - count=quantity - ) + # Always send codes as file + codes_file_content = "\n".join(created_codes) + codes_file = types.BufferedInputFile( + codes_file_content.encode('utf-8'), + filename=f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + ) + + await target_message.answer_document( + codes_file, + caption=_( + "admin_bulk_promo_codes_file", + default="📄 Все созданные промокоды в файле ({count} шт.)", + count=quantity ) + ) except Exception as e: logging.error(f"Error creating bulk promo codes: {e}") diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 2224371..9355b0c 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -152,16 +152,14 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s stats_text = _( "inline_user_stats_message", - default="👥 Статистика пользователей\n\n" + default="📊 Статистика Бота\n👥 Пользователи\n\n" "📊 Всего: {total}\n" - "📈 Активных сегодня: {active_today}\n" "💳 С платной подпиской: {paid}\n" "🆓 На пробном периоде: {trial}\n" "😴 Неактивных: {inactive}\n" "🚫 Заблокированных: {banned}\n" - "🎁 По реферальной программе: {referral}", + "🎁 Привлечено по реферальной программе: {referral}", total=user_stats['total_users'], - active_today=user_stats['active_today'], paid=user_stats['paid_subscriptions'], trial=user_stats['trial_users'], inactive=user_stats['inactive_users'], @@ -173,11 +171,11 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s id="admin_user_stats", title=_( "inline_admin_user_stats_title", - default="👥 Статистика пользователей" + default="📊 Статистика пользователей" ), description=_( - "inline_stats_description", - default="Всего: {total}, Активных: {active}", + "inline_user_stats_description", + default="Всего: {total}, Платных: {active}", total=user_stats['total_users'], active=user_stats['paid_subscriptions'] ), @@ -240,53 +238,88 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]: - """Create system statistics result with online/offline/expired/limited info""" + """Create panel statistics result with system/nodes/bandwidth info""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) try: - from datetime import datetime, timezone - from sqlalchemy import select, func, and_ - from db.models import User, Subscription + from bot.services.panel_api_service import PanelApiService - now = datetime.now(timezone.utc) - - # Count active subscriptions (online) - active_subs_stmt = select(func.count(Subscription.subscription_id)).where( - and_( - Subscription.is_active == True, - Subscription.end_date > now - ) - ) - active_subs = (await session.execute(active_subs_stmt)).scalar() or 0 - - # Count expired subscriptions - expired_subs_stmt = select(func.count(Subscription.subscription_id)).where( - and_( - Subscription.is_active == True, - Subscription.end_date <= now - ) - ) - expired_subs = (await session.execute(expired_subs_stmt)).scalar() or 0 - - # Count total users (approximation for "total") - total_users_stmt = select(func.count(User.user_id)) - total_users = (await session.execute(total_users_stmt)).scalar() or 0 - - # Offline = users without active subscriptions - offline_users = total_users - active_subs - - stats_text = _( - "inline_system_stats_message", - default="🖥 Системная статистика\n\n" - "🟢 Онлайн: {online}\n" - "🔴 Офлайн: {offline}\n" - "⏰ Истекшие: {expired}\n" - "👥 Всего пользователей: {total}", - online=active_subs, - offline=max(0, offline_users), - expired=expired_subs, - total=total_users - ) + # Get panel stats similar to main statistics + async with PanelApiService(settings) as panel_service: + system_stats = await panel_service.get_system_stats() + bandwidth_stats = await panel_service.get_bandwidth_stats() + nodes_stats = await panel_service.get_nodes_statistics() + + if system_stats: + users = system_stats.get('users', {}) + status_counts = users.get('statusCounts', {}) + online_stats = system_stats.get('onlineStats', {}) + + active_users = status_counts.get('ACTIVE', 0) + disabled_users = status_counts.get('DISABLED', 0) + expired_users = status_counts.get('EXPIRED', 0) + limited_users = status_counts.get('LIMITED', 0) + total_users = users.get('totalUsers', 0) + online_now = online_stats.get('onlineNow', 0) + + # Memory usage + memory = system_stats.get('memory', {}) + memory_usage = 0 + if memory: + memory_total = memory.get('total', 1) + memory_used = memory.get('used', 0) + memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0 + + # Bandwidth + week_traffic = "N/A" + month_traffic = "N/A" + if bandwidth_stats: + week_data = bandwidth_stats.get('bandwidthLastSevenDays', {}) + month_data = bandwidth_stats.get('bandwidthLast30Days', {}) or bandwidth_stats.get('bandwidthLastThirtyDays', {}) + + week_traffic = week_data.get('current', 'N/A') if week_data else 'N/A' + month_traffic = month_data.get('current', 'N/A') if month_data else 'N/A' + + # Nodes + active_nodes = 0 + total_nodes = 0 + if nodes_stats and 'lastSevenDays' in nodes_stats: + unique_nodes = set() + for node_data in nodes_stats.get('lastSevenDays', []): + unique_nodes.add(node_data.get('nodeName', '')) + total_nodes = len(unique_nodes) + active_nodes = total_nodes # Assume all are active + elif system_stats and 'nodes' in system_stats: + active_nodes = system_stats.get('nodes', {}).get('totalOnline', 0) + total_nodes = active_nodes + + stats_text = _( + "inline_system_stats_message", + default="🖥 Статистика панели\n\n" + "🟢 Онлайн: {online}\n" + "📊 Активных: {active}\n" + "🔴 Отключенных: {disabled}\n" + "⏰ Истекшие: {expired}\n" + "⚠️ Ограниченные: {limited}\n" + "👥 Всего пользователей: {total}\n" + "💾 Использование RAM: {memory:.1f}%\n" + "📊 Трафик за неделю: {week_traffic}\n" + "📊 Трафик за месяц: {month_traffic}\n" + "🔗 Активных нод: {active_nodes}/{total_nodes}", + online=online_now, + active=active_users, + disabled=disabled_users, + expired=expired_users, + limited=limited_users, + total=total_users, + memory=memory_usage, + week_traffic=week_traffic, + month_traffic=month_traffic, + active_nodes=active_nodes, + total_nodes=total_nodes + ) + else: + stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") return InlineQueryResultArticle( id="admin_system_stats", @@ -296,9 +329,7 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: ), description=_( "inline_system_description", - default="Онлайн: {online}, Офлайн: {offline}", - online=active_subs, - offline=max(0, offline_users) + default="Панель: онлайн, ноды, трафик" ), input_message_content=InputTextMessageContent( message_text=stats_text, @@ -309,6 +340,22 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: except Exception as e: logging.error(f"Error creating system stats result: {e}") + # Fallback error message + error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") + + return InlineQueryResultArticle( + id="admin_system_stats", + title=_( + "inline_admin_system_stats_title", + default="🖥 Системная статистика" + ), + description=_("inline_system_error", default="Ошибка получения данных"), + input_message_content=InputTextMessageContent( + message_text=error_text, + parse_mode="HTML" + ), + thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL + ) return None diff --git a/locales/en.json b/locales/en.json index 50d6ab8..48ebd31 100644 --- a/locales/en.json +++ b/locales/en.json @@ -305,7 +305,7 @@ "admin_promo_codes_menu_bulk_create": "📦 Bulk Creation", "admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\nquantity|days|description\n\nExample:\n10|7|Weekly promo", "admin_bulk_promo_invalid_format": "❌ Invalid format. Use: quantity|days|description", - "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!\n\nDescription: {description}\nCodes: {codes}", + "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!", "admin_logs_export_csv": "📄 Export to CSV", "admin_logs_csv_export_started": "📄 Starting log export to CSV...", @@ -425,6 +425,8 @@ "admin_panel_stats_header": "Panel Statistics", "admin_panel_online_label": "Online", + "admin_panel_active_label": "Active", + "admin_panel_disabled_label": "Disabled", "admin_panel_offline_label": "Offline", "admin_panel_expired_label": "Expired", "admin_panel_limited_label": "Limited", diff --git a/locales/ru.json b/locales/ru.json index 86f83ff..b8fce60 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -305,7 +305,7 @@ "admin_promo_codes_menu_bulk_create": "📦 Массовое создание", "admin_bulk_promo_create_prompt": "📦 Массовое создание промокодов\n\nВведите данные в формате:\nколичество|дни|описание\n\nПример:\n10|7|Промо на неделю", "admin_bulk_promo_invalid_format": "❌ Неверный формат. Используйте: количество|дни|описание", - "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!\n\nОписание: {description}\nПромокоды: {codes}", + "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!", "admin_logs_export_csv": "📄 Экспорт в CSV", "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", @@ -425,6 +425,8 @@ "admin_panel_stats_header": "Статистика панели", "admin_panel_online_label": "Онлайн", + "admin_panel_active_label": "Активных", + "admin_panel_disabled_label": "Отключенных", "admin_panel_offline_label": "Офлайн", "admin_panel_expired_label": "Истекшие", "admin_panel_limited_label": "Ограниченные", From 9688b0f77337051ad21c95bc4f96d8f1d742489d Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 22:00:24 +0300 Subject: [PATCH 12/26] Update localization files for enhanced system and user statistics - Revised inline system statistics message to reflect updated terminology and additional metrics, including active and disabled users, memory usage, and traffic statistics. - Enhanced inline user statistics message for clarity and added a new description field for better user understanding. - Updated Russian localization to match the changes made in the English version, ensuring consistency across languages. --- locales/en.json | 3 ++- locales/ru.json | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/locales/en.json b/locales/en.json index 48ebd31..9b5d0eb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -283,7 +283,7 @@ "inline_admin_financial_stats_title": "💰 Financial Statistics", "inline_admin_financial_stats_desc": "Today: {today:.2f} RUB", - "inline_system_stats_message": "🖥 System Statistics\n\n🟢 Online: {online}\n🔴 Offline: {offline}\n⏰ Expired: {expired}\n👥 Total users: {total}", + "inline_system_stats_message": "🖥 Panel Statistics\n\n🟢 Online: {online}\n📊 Active: {active}\n🔴 Disabled: {disabled}\n⏰ Expired: {expired}\n⚠️ Limited: {limited}\n👥 Total users: {total}\n💾 RAM Usage: {memory:.1f}%\n📊 Week traffic: {week_traffic}\n📊 Month traffic: {month_traffic}\n🔗 Active nodes: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 System Statistics", "inline_admin_system_stats_desc": "Online: {online}, Offline: {offline}", @@ -306,6 +306,7 @@ "admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\nquantity|days|description\n\nExample:\n10|7|Weekly promo", "admin_bulk_promo_invalid_format": "❌ Invalid format. Use: quantity|days|description", "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!", + "admin_bulk_promo_codes_file": "📄 All created promo codes in file ({count} pcs.)", "admin_logs_export_csv": "📄 Export to CSV", "admin_logs_csv_export_started": "📄 Starting log export to CSV...", diff --git a/locales/ru.json b/locales/ru.json index b8fce60..d59c569 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -275,7 +275,8 @@ "inline_referral_title": "🎁 Пригласить друга", "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", - "inline_user_stats_message": "👥 Статистика пользователей\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 По реферальной программе: {referral}", + "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", + "inline_user_stats_description": "Всего: {total}, Платных: {active}", "inline_admin_user_stats_title": "👥 Статистика пользователей", "inline_admin_user_stats_desc": "Всего: {total}, Активных: {paid}", @@ -283,7 +284,7 @@ "inline_admin_financial_stats_title": "💰 Финансовая статистика", "inline_admin_financial_stats_desc": "Сегодня: {today:.2f} RUB", - "inline_system_stats_message": "🖥 Системная статистика\n\n🟢 Онлайн: {online}\n🔴 Офлайн: {offline}\n⏰ Истекшие: {expired}\n👥 Всего пользователей: {total}", + "inline_system_stats_message": "🖥 Статистика панели\n\n🟢 Онлайн: {online}\n📊 Активных: {active}\n🔴 Отключенных: {disabled}\n⏰ Истекшие: {expired}\n⚠️ Ограниченные: {limited}\n👥 Всего пользователей: {total}\n💾 Использование RAM: {memory:.1f}%\n📊 Трафик за неделю: {week_traffic}\n📊 Трафик за месяц: {month_traffic}\n🔗 Активных нод: {active_nodes}/{total_nodes}", "inline_admin_system_stats_title": "🖥 Системная статистика", "inline_admin_system_stats_desc": "Онлайн: {online}, Офлайн: {offline}", @@ -306,6 +307,7 @@ "admin_bulk_promo_create_prompt": "📦 Массовое создание промокодов\n\nВведите данные в формате:\nколичество|дни|описание\n\nПример:\n10|7|Промо на неделю", "admin_bulk_promo_invalid_format": "❌ Неверный формат. Используйте: количество|дни|описание", "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!", + "admin_bulk_promo_codes_file": "📄 Все созданные промокоды в файле ({count} шт.)", "admin_logs_export_csv": "📄 Экспорт в CSV", "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", From fc48cd7a09ecea4e384506d1ba668e22e62e6623 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 22:06:22 +0300 Subject: [PATCH 13/26] Update inline system statistics localization for clarity and consistency - Revised the inline system statistics description in both English and Russian to include updated terminology and additional metrics, enhancing user understanding. - Ensured consistency across localization files by aligning the descriptions in both languages. --- bot/handlers/inline_mode.py | 4 +++- locales/en.json | 2 +- locales/ru.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 9355b0c..93528cb 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -329,7 +329,9 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: ), description=_( "inline_system_description", - default="Панель: онлайн, ноды, трафик" + default="🟢 Онлайн: {online}, 📊 Активных: {active}", + online=online_now, + active=active_users ), input_message_content=InputTextMessageContent( message_text=stats_text, diff --git a/locales/en.json b/locales/en.json index 9b5d0eb..a7d5717 100644 --- a/locales/en.json +++ b/locales/en.json @@ -452,7 +452,7 @@ "inline_financial_title": "Financial statistics", "inline_financial_description": "Today: {today} RUB", "inline_system_title": "System statistics", - "inline_system_description": "Online: {online}, Offline: {offline}", + "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", "inline_admin_help_title": "Commands help", "inline_admin_help_description": "Available commands: ref, stat, financial, system", "inline_user_help_title": "Commands help", diff --git a/locales/ru.json b/locales/ru.json index d59c569..438d89e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -453,7 +453,7 @@ "inline_financial_title": "Финансовая статистика", "inline_financial_description": "Сегодня: {today} RUB", "inline_system_title": "Системная статистика", - "inline_system_description": "Онлайн: {online}, Офлайн: {offline}", + "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", "inline_admin_help_title": "Помощь по командам", "inline_admin_help_description": "Доступны команды: реф, стат, финансы, система", "inline_user_help_title": "Помощь по командам", From b13883c03985c5a3ad66550a8887d4500625c8e1 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 22:12:49 +0300 Subject: [PATCH 14/26] Enhance promo code editing functionality in admin panel - Implemented a four-step process for editing promo codes, allowing admins to update code, bonus days, max activations, and validity period with improved user prompts. - Added new states to manage each step of the editing process, ensuring better flow and validation. - Updated localization files to include new messages and prompts for the editing steps in both English and Russian, enhancing user experience. --- bot/handlers/admin/promo_codes.py | 285 +++++++++++++++++++++++++----- bot/states/admin_states.py | 4 + locales/en.json | 18 +- locales/ru.json | 18 +- 4 files changed, 270 insertions(+), 55 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 6f2a175..82abd97 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -653,63 +653,258 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMCon return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - await state.update_data(edit_promo_id=promo_id) - await state.set_state(AdminStates.waiting_for_promo_edit_details) + await state.update_data( + edit_promo_id=promo_id, + original_code=promo.code, + original_bonus_days=promo.bonus_days, + original_max_activations=promo.max_activations, + original_valid_until=promo.valid_until + ) + + # Step 1: Edit code + prompt_text = _( + "admin_promo_edit_step1_code", + default="✏️ Редактирование промокода\n\nШаг 1 из 4: Код промокода\n\nТекущий код: {current_code}\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:", + current_code=promo.code + ) + + await state.set_state(AdminStates.waiting_for_promo_edit_code) await callback.message.edit_text( - _("admin_promo_edit_prompt", code=promo.code), + prompt_text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML") await callback.answer() -@router.message(AdminStates.waiting_for_promo_edit_details, F.text) -async def process_promo_edit_details(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - data = await state.get_data() - promo_id = data.get("edit_promo_id") +# Step 1: Process edited code +@router.message(AdminStates.waiting_for_promo_edit_code, F.text) +async def process_promo_edit_code_handler(message: types.Message, state: FSMContext, + i18n_data: dict, settings: Settings, + session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not promo_id: - await message.answer("Error") - await state.clear() + if not i18n: + await message.reply("Language service error.") return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - parts = message.text.strip().split() - if not (3 <= len(parts) <= 4): - await message.answer(_("admin_promo_invalid_format")) - return + + data = await state.get_data() + original_code = data.get("original_code") + try: - code_str = parts[0].upper() - bonus = int(parts[1]) - max_act = int(parts[2]) - valid_until = None - if len(parts) == 4: - days = int(parts[3]) - valid_until = datetime.now(timezone.utc) + timedelta(days=days) - except Exception: - await message.answer(_("admin_promo_invalid_format_general")) - return - - update_data = { - "code": code_str, - "bonus_days": bonus, - "max_activations": max_act, - "valid_until": valid_until, - } - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: - await session.commit() - await message.answer( - _("admin_promo_updated_success", code=code_str), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML", + code_str = message.text.strip().upper() + if not (3 <= len(code_str) <= 30 and code_str.isalnum()): + await message.answer(_( + "admin_promo_invalid_code_format", + default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + )) + return + + # Check if code already exists (except current promo) + if code_str != original_code: + existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) + if existing_promo: + await message.answer(_( + "admin_promo_code_already_exists", + default="❌ Промокод с таким кодом уже существует" + )) + return + + await state.update_data(new_code=code_str) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_promo_edit_step2_bonus_days", + default="✏️ Редактирование промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\nТекущие дни: {current_days}\n\nВведите количество бонусных дней (1-365):", + code=code_str, + current_days=data.get("original_bonus_days") ) - else: - await session.rollback() - await message.answer(_("admin_promo_not_found")) - await state.clear() + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_edit_bonus_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_code_format", + default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + )) + + +# Step 2: Process edited bonus days +@router.message(AdminStates.waiting_for_promo_edit_bonus_days, F.text) +async def process_promo_edit_bonus_days_handler(message: types.Message, state: FSMContext, + i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + + try: + bonus_days = int(message.text.strip()) + if not (1 <= bonus_days <= 365): + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество дней должно быть от 1 до 365" + )) + return + + await state.update_data(new_bonus_days=bonus_days) + + # Step 3: Ask for max activations + prompt_text = _( + "admin_promo_edit_step3_max_activations", + default="✏️ Редактирование промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nДни: {days}\nТекущие активации: {current_max}\n\nВведите максимальное количество активаций (1-10000):", + code=data.get("new_code"), + days=bonus_days, + current_max=data.get("original_max_activations") + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_edit_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество дней должно быть от 1 до 365" + )) + + +# Step 3: Process edited max activations +@router.message(AdminStates.waiting_for_promo_edit_max_activations, F.text) +async def process_promo_edit_max_activations_handler(message: types.Message, state: FSMContext, + i18n_data: dict, settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + + try: + max_activations = int(message.text.strip()) + if not (1 <= max_activations <= 10000): + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Количество активаций должно быть от 1 до 10000" + )) + return + + await state.update_data(new_max_activations=max_activations) + + # Step 4: Ask for validity days + current_valid_until = data.get("original_valid_until") + current_validity_str = _("admin_promo_valid_indefinitely", default="Неограниченно") + if current_valid_until: + current_validity_str = current_valid_until.strftime('%Y-%m-%d') + + prompt_text = _( + "admin_promo_edit_step4_validity", + default="✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВведите количество дней действия промокода от сегодня (1-365) или 0 для неограниченного срока:", + code=data.get("new_code"), + days=data.get("new_bonus_days"), + max_act=max_activations, + current_validity=current_validity_str + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_edit_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Количество активаций должно быть от 1 до 10000" + )) + + +# Step 4: Process edited validity and finalize +@router.message(AdminStates.waiting_for_promo_edit_validity_days, F.text) +async def process_promo_edit_validity_handler(message: types.Message, state: FSMContext, + i18n_data: dict, settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + promo_id = data.get("edit_promo_id") + + try: + validity_days = int(message.text.strip()) + if not (0 <= validity_days <= 365): + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)" + )) + return + + # Calculate validity date + valid_until_date = None + valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") + if validity_days > 0: + valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) + valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') + + # Update promo code + update_data = { + "code": data.get("new_code"), + "bonus_days": data.get("new_bonus_days"), + "max_activations": data.get("new_max_activations"), + "valid_until": valid_until_date, + } + + updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if updated: + await session.commit() + + success_text = _( + "admin_promo_updated_success", + default="✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + code=data.get("new_code"), + days=data.get("new_bonus_days"), + max_act=data.get("new_max_activations"), + validity=valid_until_str_display + ) + + await message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + else: + await session.rollback() + await message.answer(_( + "admin_promo_update_failed", + default="❌ Ошибка обновления промокода" + )) + + await state.clear() + + except ValueError: + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)" + )) @router.callback_query(F.data.startswith("promo_delete:")) diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 3b8af20..330e53e 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -11,6 +11,10 @@ class AdminStates(StatesGroup): waiting_for_promo_max_activations = State() waiting_for_promo_validity_days = State() waiting_for_promo_edit_details = State() + waiting_for_promo_edit_code = State() + waiting_for_promo_edit_bonus_days = State() + waiting_for_promo_edit_max_activations = State() + waiting_for_promo_edit_validity_days = State() waiting_for_bulk_promo_quantity = State() waiting_for_bulk_promo_bonus_days = State() waiting_for_bulk_promo_max_activations = State() diff --git a/locales/en.json b/locales/en.json index a7d5717..6c68dcc 100644 --- a/locales/en.json +++ b/locales/en.json @@ -104,11 +104,11 @@ "admin_sync_panel_button": "🔄 Sync", "admin_unknown_action": "Unknown admin action.", - "admin_stats_and_monitoring_section": "📊 Statistics & Monitoring", - "admin_user_management_section": "👥 User Management", - "admin_promo_marketing_section": "🎁 Promos & Marketing", - "admin_system_functions_section": "⚙️ System Functions", - "admin_ban_management_section": "🚫 Ban Management", + "admin_stats_and_monitoring_section": "📊 Statistics", + "admin_user_management_section": "👥 Users", + "admin_promo_marketing_section": "🎁 Promos", + "admin_system_functions_section": "⚙️ System", + "admin_ban_management_section": "🚫 Bans", "admin_users_management_button": "👤 User Management", "back_to_user_management_button": "⬅️ Back to User Management", "admin_action_cancelled_default": "Action cancelled. Returning to menu.", @@ -308,6 +308,14 @@ "admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!", "admin_bulk_promo_codes_file": "📄 All created promo codes in file ({count} pcs.)", + "admin_promo_edit_step1_code": "✏️ Editing Promo Code\n\nStep 1 of 4: Promo Code\n\nCurrent code: {current_code}\n\nEnter new promo code (3-30 characters, letters and numbers only) or send current one to keep:", + "admin_promo_edit_step2_bonus_days": "✏️ Editing Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\nCurrent days: {current_days}\n\nEnter number of bonus days (1-365):", + "admin_promo_edit_step3_max_activations": "✏️ Editing Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nDays: {days}\nCurrent activations: {current_max}\n\nEnter maximum number of activations (1-10000):", + "admin_promo_edit_step4_validity": "✏️ Editing Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nDays: {days}\nActivations: {max_act}\nCurrent validity: {current_validity}\n\nEnter number of days the promo code is valid from today (1-365) or 0 for unlimited:", + "admin_promo_updated_success": "✅ Promo code updated!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", + "admin_promo_update_failed": "❌ Error updating promo code", + "admin_promo_invalid_validity_days": "❌ Validity days must be from 0 to 365 days (0 = unlimited)", + "admin_logs_export_csv": "📄 Export to CSV", "admin_logs_csv_export_started": "📄 Starting log export to CSV...", "admin_logs_csv_export_success": "✅ Logs exported! File attached above.", diff --git a/locales/ru.json b/locales/ru.json index 438d89e..574bcc8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -104,11 +104,11 @@ "admin_sync_panel_button": "🔄 Синхронизация", "admin_unknown_action": "Неизвестное действие администратора.", - "admin_stats_and_monitoring_section": "📊 Статистика и мониторинг", - "admin_user_management_section": "👥 Управление пользователями", - "admin_promo_marketing_section": "🎁 Промокоды и маркетинг", - "admin_system_functions_section": "⚙️ Системные функции", - "admin_ban_management_section": "🚫 Управление блокировками", + "admin_stats_and_monitoring_section": "📊 Статистика", + "admin_user_management_section": "👥 Пользователи", + "admin_promo_marketing_section": "🎁 Промокоды", + "admin_system_functions_section": "⚙️ Система", + "admin_ban_management_section": "🚫 Блокировки", "admin_users_management_button": "👤 Управление пользователями", "back_to_user_management_button": "⬅️ К управлению пользователями", "admin_action_cancelled_default": "Действие отменено. Возврат в меню.", @@ -309,6 +309,14 @@ "admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!", "admin_bulk_promo_codes_file": "📄 Все созданные промокоды в файле ({count} шт.)", + "admin_promo_edit_step1_code": "✏️ Редактирование промокода\n\nШаг 1 из 4: Код промокода\n\nТекущий код: {current_code}\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:", + "admin_promo_edit_step2_bonus_days": "✏️ Редактирование промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\nТекущие дни: {current_days}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_edit_step3_max_activations": "✏️ Редактирование промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nДни: {days}\nТекущие активации: {current_max}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_edit_step4_validity": "✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВведите количество дней действия промокода от сегодня (1-365) или 0 для неограниченного срока:", + "admin_promo_updated_success": "✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + "admin_promo_update_failed": "❌ Ошибка обновления промокода", + "admin_promo_invalid_validity_days": "❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)", + "admin_logs_export_csv": "📄 Экспорт в CSV", "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", "admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.", From ce041758f13afe2a7a75dc35f32eed370a717024 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 22:27:41 +0300 Subject: [PATCH 15/26] Enhance referral program features and localization updates - Implemented referral statistics retrieval to provide users with insights on invited friends and subscription purchases. - Updated referral command handler to include new statistics in the response message, improving user engagement. - Revised localization files for both English and Russian to reflect changes in the referral program messaging, enhancing clarity and user experience. --- bot/handlers/admin/promo_codes.py | 131 +++++++++++++++++++++++++++--- bot/handlers/inline_mode.py | 11 +-- bot/handlers/user/referral.py | 7 +- bot/services/referral_service.py | 37 +++++++++ locales/en.json | 13 +-- locales/ru.json | 13 +-- 6 files changed, 183 insertions(+), 29 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 82abd97..a7ffceb 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -805,15 +805,35 @@ async def process_promo_edit_max_activations_handler(message: types.Message, sta await state.update_data(new_max_activations=max_activations) - # Step 4: Ask for validity days + # Step 4: Ask for validity days (with buttons) current_valid_until = data.get("original_valid_until") current_validity_str = _("admin_promo_valid_indefinitely", default="Неограниченно") if current_valid_until: current_validity_str = current_valid_until.strftime('%Y-%m-%d') + keyboard = InlineKeyboardBuilder() + keyboard.row( + InlineKeyboardButton( + text=_("admin_promo_edit_unlimited_validity", default="♾️ Неограниченно"), + callback_data="promo_edit_unlimited_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_promo_edit_set_validity", default="⏰ Установить срок"), + callback_data="promo_edit_set_validity" + ) + ) + keyboard.row( + InlineKeyboardButton( + text=_("admin_panel_back_button", default="⬅️ Назад"), + callback_data="admin_panel_back" + ) + ) + prompt_text = _( "admin_promo_edit_step4_validity", - default="✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВведите количество дней действия промокода от сегодня (1-365) или 0 для неограниченного срока:", + default="✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВыберите срок действия промокода:", code=data.get("new_code"), days=data.get("new_bonus_days"), max_act=max_activations, @@ -822,7 +842,7 @@ async def process_promo_edit_max_activations_handler(message: types.Message, sta await message.answer( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + reply_markup=keyboard.as_markup(), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_promo_edit_validity_days) @@ -834,6 +854,98 @@ async def process_promo_edit_max_activations_handler(message: types.Message, sta )) +# Handle edit validity choice buttons +@router.callback_query(F.data == "promo_edit_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_edit_validity_days)) +async def process_promo_edit_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + promo_id = data.get("edit_promo_id") + + # Update promo code with unlimited validity + update_data = { + "code": data.get("new_code"), + "bonus_days": data.get("new_bonus_days"), + "max_activations": data.get("new_max_activations"), + "valid_until": None, # Unlimited + } + + # Show updating message + try: + await callback.message.edit_text( + _("admin_promo_creating", default="⏳ Обновляю промокод..."), + ) + except: + await callback.message.answer( + _("admin_promo_creating", default="⏳ Обновляю промокод..."), + ) + await callback.answer() + + updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if updated: + await session.commit() + + success_text = _( + "admin_promo_updated_success", + default="✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + code=data.get("new_code"), + days=data.get("new_bonus_days"), + max_act=data.get("new_max_activations"), + validity=_("admin_promo_valid_indefinitely", default="Неограниченно") + ) + + await callback.message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + else: + await session.rollback() + await callback.message.answer(_( + "admin_promo_update_failed", + default="❌ Ошибка обновления промокода" + )) + + await state.clear() + +@router.callback_query(F.data == "promo_edit_set_validity", StateFilter(AdminStates.waiting_for_promo_edit_validity_days)) +async def process_promo_edit_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_promo_edit_enter_validity_days", + default="⏰ Введите количество дней действия промокода (1-365):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + await callback.answer() + # Step 4: Process edited validity and finalize @router.message(AdminStates.waiting_for_promo_edit_validity_days, F.text) async def process_promo_edit_validity_handler(message: types.Message, state: FSMContext, @@ -851,19 +963,16 @@ async def process_promo_edit_validity_handler(message: types.Message, state: FSM try: validity_days = int(message.text.strip()) - if not (0 <= validity_days <= 365): + if not (1 <= validity_days <= 365): await message.answer(_( "admin_promo_invalid_validity_days", - default="❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)" + default="❌ Дни действия должны быть от 1 до 365" )) return # Calculate validity date - valid_until_date = None - valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") - if validity_days > 0: - valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) - valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') + valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) + valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') # Update promo code update_data = { @@ -903,7 +1012,7 @@ async def process_promo_edit_validity_handler(message: types.Message, state: FSM except ValueError: await message.answer(_( "admin_promo_invalid_validity_days", - default="❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)" + default="❌ Дни действия должны быть от 1 до 365" )) diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 93528cb..83dd4fb 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -82,14 +82,11 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot, user_id = inline_query.from_user.id referral_link = referral_service.generate_referral_link(bot_username, user_id) - # Create message content + # Create message content (use same text as friend message) message_text = _( - "inline_referral_message", - default="🚀 Привет! Попробуй этот крутой VPN сервис!\n\n" - "✨ Быстрый и надежный\n" - "🔒 Полная анонимность\n" - "🌍 Серверы по всему миру\n" - "💎 Бесплатный пробный период\n\n{referral_link}", + "referral_friend_message", + default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n" + "🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", referral_link=referral_link ) diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index be6b418..7536370 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -83,9 +83,14 @@ async def referral_command_handler(event: Union[types.Message, bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( "referral_no_bonuses_configured") + # Get referral statistics + referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) + text = _("referral_program_info_new", referral_link=referral_link, - bonus_details=bonus_details_str) + bonus_details=bonus_details_str, + invited_count=referral_stats["invited_count"], + purchased_count=referral_stats["purchased_count"]) from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard reply_markup_val = get_referral_link_keyboard(current_lang, i18n) diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 30681d8..1be6063 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -1,5 +1,6 @@ import logging from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text from typing import Optional, Dict, Any from aiogram import Bot from datetime import datetime, timezone, timedelta @@ -219,3 +220,39 @@ class ReferralService: def generate_referral_link(self, bot_username: str, inviter_user_id: int) -> str: return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}" + + async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict: + """Get referral statistics for a user""" + from db.dal import user_dal, payment_dal + + try: + # Count total invited users (referrals) + invited_count_result = await session.execute( + text("SELECT COUNT(*) FROM users WHERE referred_by_id = :user_id"), + {"user_id": user_id} + ) + invited_count = invited_count_result.scalar() or 0 + + # Count users who made successful payments (purchased subscription) + purchased_count_result = await session.execute( + text(""" + SELECT COUNT(DISTINCT u.user_id) + FROM users u + JOIN payments p ON u.user_id = p.user_id + WHERE u.referred_by_id = :user_id + AND p.status = 'succeeded' + """), + {"user_id": user_id} + ) + purchased_count = purchased_count_result.scalar() or 0 + + return { + "invited_count": invited_count, + "purchased_count": purchased_count + } + except Exception as e: + logging.error(f"Error getting referral stats for user {user_id}: {e}") + return { + "invited_count": 0, + "purchased_count": 0 + } diff --git a/locales/en.json b/locales/en.json index 6c68dcc..e1b5960 100644 --- a/locales/en.json +++ b/locales/en.json @@ -77,7 +77,7 @@ "no_button": "No", "trial_cancelled_short": "Activation cancelled.", - "referral_program_info_new": "Invite friends and earn bonuses!\n\nYour referral link:\n{referral_link}\n\nBonuses:{bonus_details}", + "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Your link:\n{referral_link}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", "no_bonus_days": "0", "referral_no_bonuses_configured": "\nNo referral bonuses configured.", @@ -271,7 +271,7 @@ "admin_user_logs_error": "❌ Error loading user actions", "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", - "inline_referral_message": "🚀 Hey! Try this awesome VPN service!\n\n✨ Fast and reliable\n🔒 Complete anonymity\n🌍 Servers worldwide\n💎 Free trial period\n\nCheck it out: {referral_link}", + "inline_referral_title": "🎁 Invite a Friend", "inline_referral_description": "Share referral link to get bonuses", @@ -311,10 +311,13 @@ "admin_promo_edit_step1_code": "✏️ Editing Promo Code\n\nStep 1 of 4: Promo Code\n\nCurrent code: {current_code}\n\nEnter new promo code (3-30 characters, letters and numbers only) or send current one to keep:", "admin_promo_edit_step2_bonus_days": "✏️ Editing Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\nCurrent days: {current_days}\n\nEnter number of bonus days (1-365):", "admin_promo_edit_step3_max_activations": "✏️ Editing Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nDays: {days}\nCurrent activations: {current_max}\n\nEnter maximum number of activations (1-10000):", - "admin_promo_edit_step4_validity": "✏️ Editing Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nDays: {days}\nActivations: {max_act}\nCurrent validity: {current_validity}\n\nEnter number of days the promo code is valid from today (1-365) or 0 for unlimited:", + "admin_promo_edit_step4_validity": "✏️ Editing Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nDays: {days}\nActivations: {max_act}\nCurrent validity: {current_validity}\n\nChoose the validity period for the promo code:", + "admin_promo_edit_unlimited_validity": "♾️ Unlimited", + "admin_promo_edit_set_validity": "⏰ Set Period", + "admin_promo_edit_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", "admin_promo_updated_success": "✅ Promo code updated!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", "admin_promo_update_failed": "❌ Error updating promo code", - "admin_promo_invalid_validity_days": "❌ Validity days must be from 0 to 365 days (0 = unlimited)", + "admin_promo_invalid_validity_days": "❌ Validity days must be from 1 to 365", "admin_logs_export_csv": "📄 Export to CSV", "admin_logs_csv_export_started": "📄 Starting log export to CSV...", @@ -452,7 +455,7 @@ "admin_panel_stats_fetch_error": "Error fetching panel data", "admin_panel_stats_error_details": "Details", - "inline_referral_message": "🎁 Get bonuses for friends!\n\nConnect friends to fast VPN and get bonus days for each new user.\n\n💰 Your referral link:\n{referral_link}", + "inline_referral_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "inline_referral_title": "Referral link", "inline_referral_description": "Share referral link to get bonuses", "inline_stats_title": "Bot statistics", diff --git a/locales/ru.json b/locales/ru.json index 574bcc8..7c199c8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -77,7 +77,7 @@ "no_button": "Нет", "trial_cancelled_short": "Активация отменена.", - "referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n{referral_link}\n\nБонусы:{bonus_details}", + "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Твоя ссылка:\n{referral_link}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", "no_bonus_days": "0", "referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.", @@ -271,7 +271,7 @@ "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", - "inline_referral_message": "🚀 Привет! Попробуй этот крутой VPN сервис!\n\n✨ Быстрый и надежный\n🔒 Полная анонимность\n🌍 Серверы по всему миру\n💎 Бесплатный пробный период\n\nПереходи по ссылке: {referral_link}", + "inline_referral_title": "🎁 Пригласить друга", "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", @@ -312,10 +312,13 @@ "admin_promo_edit_step1_code": "✏️ Редактирование промокода\n\nШаг 1 из 4: Код промокода\n\nТекущий код: {current_code}\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:", "admin_promo_edit_step2_bonus_days": "✏️ Редактирование промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\nТекущие дни: {current_days}\n\nВведите количество бонусных дней (1-365):", "admin_promo_edit_step3_max_activations": "✏️ Редактирование промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nДни: {days}\nТекущие активации: {current_max}\n\nВведите максимальное количество активаций (1-10000):", - "admin_promo_edit_step4_validity": "✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВведите количество дней действия промокода от сегодня (1-365) или 0 для неограниченного срока:", + "admin_promo_edit_step4_validity": "✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВыберите срок действия промокода:", + "admin_promo_edit_unlimited_validity": "♾️ Неограниченно", + "admin_promo_edit_set_validity": "⏰ Установить срок", + "admin_promo_edit_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", "admin_promo_updated_success": "✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", "admin_promo_update_failed": "❌ Ошибка обновления промокода", - "admin_promo_invalid_validity_days": "❌ Срок действия должен быть от 0 до 365 дней (0 = неограниченно)", + "admin_promo_invalid_validity_days": "❌ Дни действия должны быть от 1 до 365", "admin_logs_export_csv": "📄 Экспорт в CSV", "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", @@ -453,7 +456,7 @@ "admin_panel_stats_fetch_error": "Ошибка получения данных с панели", "admin_panel_stats_error_details": "Детали", - "inline_referral_message": "🎁 Получи бонусы за друзей!\n\nПодключай друзей к быстрому VPN и получай бонусные дни за каждого нового пользователя.\n\n💰 Твоя реферальная ссылка:\n{referral_link}", + "inline_referral_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "inline_referral_title": "Реферальная ссылка", "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", "inline_stats_title": "Статистика бота", From bcd95fb56c3a53b5222af7d859412c4357d817c8 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Sun, 3 Aug 2025 22:39:15 +0300 Subject: [PATCH 16/26] Update localization files for admin panel buttons and sections - Revised button labels in English and Russian localization files for improved clarity and conciseness, including changes to promo management, user management, and navigation buttons. - Ensured consistency in terminology across both languages to enhance user experience in the admin panel. --- locales/en.json | 14 +++++++------- locales/ru.json | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/locales/en.json b/locales/en.json index e1b5960..a691568 100644 --- a/locales/en.json +++ b/locales/en.json @@ -95,10 +95,10 @@ "admin_broadcast_button": "📢 Broadcast", "admin_create_promo_button": "🎁 Create Promo", "admin_create_bulk_promo_button": "📦 Bulk Create", - "admin_manage_promos_button": "🛠 Manage Promos", - "admin_view_promos_button": "👀 Promo List", - "admin_ban_user_button": "🚫 Ban User", - "admin_unban_user_button": "✅ Unban User", + "admin_manage_promos_button": "🛠 Manage", + "admin_view_promos_button": "👀 List", + "admin_ban_user_button": "🚫 Ban", + "admin_unban_user_button": "✅ Unban", "admin_view_banned_users_button": "📜 Ban List", "admin_view_logs_menu_button": "📄 Logs", "admin_sync_panel_button": "🔄 Sync", @@ -109,11 +109,11 @@ "admin_promo_marketing_section": "🎁 Promos", "admin_system_functions_section": "⚙️ System", "admin_ban_management_section": "🚫 Bans", - "admin_users_management_button": "👤 User Management", - "back_to_user_management_button": "⬅️ Back to User Management", + "admin_users_management_button": "👤 Management", + "back_to_user_management_button": "⬅️ To Users", "admin_action_cancelled_default": "Action cancelled. Returning to menu.", "admin_action_cancelled_default_alert": "Action cancelled", - "back_to_admin_panel_button": "⬅️ Back to Admin", + "back_to_admin_panel_button": "⬅️ To Admin", "admin_stats_header": "📊 Bot Statistics", "admin_enhanced_users_stats_header": "Users", diff --git a/locales/ru.json b/locales/ru.json index 7c199c8..d87ecfa 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -95,8 +95,8 @@ "admin_broadcast_button": "📢 Рассылка", "admin_create_promo_button": "🎁 Создать промо", "admin_create_bulk_promo_button": "📦 Массовое создание", - "admin_manage_promos_button": "🛠 Управление промо", - "admin_view_promos_button": "👀 Список промо", + "admin_manage_promos_button": "🛠 Управление", + "admin_view_promos_button": "👀 Список", "admin_ban_user_button": "🚫 Забанить", "admin_unban_user_button": "✅ Разбанить", "admin_view_banned_users_button": "📜 Бан-лист", @@ -109,8 +109,8 @@ "admin_promo_marketing_section": "🎁 Промокоды", "admin_system_functions_section": "⚙️ Система", "admin_ban_management_section": "🚫 Блокировки", - "admin_users_management_button": "👤 Управление пользователями", - "back_to_user_management_button": "⬅️ К управлению пользователями", + "admin_users_management_button": "👤 Управление", + "back_to_user_management_button": "⬅️ К пользователям", "admin_action_cancelled_default": "Действие отменено. Возврат в меню.", "admin_action_cancelled_default_alert": "Действие отменено", "back_to_admin_panel_button": "⬅️ В админку", From b53e6a2cc35907c7a9536c672593a31c1b976fe8 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Mon, 4 Aug 2025 14:29:01 +0300 Subject: [PATCH 17/26] Update .env.example and enhance notification service for suspicious promo attempts - Revised the .env.example file to include new configuration options for Telegram bot settings, database credentials, and payment gateways, ensuring clarity and ease of use. - Implemented a new method in the NotificationService to notify admins of suspicious promo code attempts, enhancing security and monitoring capabilities. - Updated localization files to include messages related to suspicious promo code attempts in both English and Russian, improving admin awareness and response. --- .env.example | 152 +++++++++++++++------------ bot/services/notification_service.py | 31 ++++++ locales/en.json | 23 +--- locales/ru.json | 23 +--- 4 files changed, 117 insertions(+), 112 deletions(-) diff --git a/.env.example b/.env.example index c2e417c..4c9e53b 100644 --- a/.env.example +++ b/.env.example @@ -1,69 +1,69 @@ -# Telegram Bot Token and Admin IDs -BOT_TOKEN=your_bot_token_here -ADMIN_IDS=comma_separated_admin_ids +# Telegram Bot +BOT_TOKEN= +ADMIN_IDS= +DEFAULT_LANGUAGE=ru +DEFAULT_CURRENCY_SYMBOL=RUB +SUPPORT_LINK= +SERVER_STATUS_URL= +TERMS_OF_SERVICE_URL= -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_HOST=remnawave-tg-shop-db +# Database +POSTGRES_USER=user +POSTGRES_PASSWORD=password +POSTGRES_HOST=db POSTGRES_PORT=5432 -POSTGRES_DB=postgres +POSTGRES_DB=vpn_shop_db -# Localization and Display -DEFAULT_LANGUAGE="ru" # or "en" -DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR +# Webhooks +WEBHOOK_BASE_URL= +PANEL_WEBHOOK_SECRET= -# External Links -SUPPORT_LINK=https://t.me/your_support_link -SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service -TERMS_OF_SERVICE_URL=https://example.com/tos -SUBSCRIPTION_MINI_APP_URL= -START_COMMAND_DESCRIPTION= - -# Webhook Base URL (used for Telegram and payment providers) -WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld - -# YooKassa Payment Gateway Configuration -YOOKASSA_SHOP_ID=your_shop_id -YOOKASSA_SECRET_KEY=your_secret_key -YOOKASSA_RETURN_URL=https://t.me/your_bot -YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com +# YooKassa +YOOKASSA_ENABLED=True +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= +YOOKASSA_RETURN_URL= +YOOKASSA_DEFAULT_RECEIPT_EMAIL= YOOKASSA_VAT_CODE=1 YOOKASSA_PAYMENT_MODE=full_prepayment -YOOKASSA_PAYMENT_SUBJECT=payment +YOOKASSA_PAYMENT_SUBJECT=service -# CryptoBot Payment Gateway Configuration +# CryptoPay +CRYPTOPAY_ENABLED=True CRYPTOPAY_TOKEN= CRYPTOPAY_NETWORK=mainnet CRYPTOPAY_CURRENCY_TYPE=fiat CRYPTOPAY_ASSET=RUB -# API key for verifying Tribute webhook signatures + +# Telegram Stars +STARS_ENABLED=True + +# Telegram Tribute +TRIBUTE_ENABLED=True TRIBUTE_API_KEY= -# Payment Method Toggles -YOOKASSA_ENABLED=True -STARS_ENABLED=True -TRIBUTE_ENABLED=True -CRYPTOPAY_ENABLED=True - -# Subscription Options +# Subscription Durations 1_MONTH_ENABLED=True -RUB_PRICE_1_MONTH=150 -STARS_PRICE_1_MONTH=0 -TRIBUTE_LINK_1_MONTH= - 3_MONTHS_ENABLED=True -RUB_PRICE_3_MONTHS=300 -STARS_PRICE_3_MONTHS=0 -TRIBUTE_LINK_3_MONTHS= - 6_MONTHS_ENABLED=True -RUB_PRICE_6_MONTHS=500 -STARS_PRICE_6_MONTHS=0 -TRIBUTE_LINK_6_MONTHS= - 12_MONTHS_ENABLED=True -RUB_PRICE_12_MONTHS=900 -STARS_PRICE_12_MONTHS=0 + +# Prices in RUB +RUB_PRICE_1_MONTH= +RUB_PRICE_3_MONTHS= +RUB_PRICE_6_MONTHS= +RUB_PRICE_12_MONTHS= + +# Prices in Telegram Stars +STARS_PRICE_1_MONTH= +STARS_PRICE_3_MONTHS= +STARS_PRICE_6_MONTHS= +STARS_PRICE_12_MONTHS= + +# Tribute Payment Links +TRIBUTE_LINK_1_MONTH= +TRIBUTE_LINK_3_MONTHS= +TRIBUTE_LINK_6_MONTHS= TRIBUTE_LINK_12_MONTHS= # Subscription Notifications @@ -72,38 +72,52 @@ SUBSCRIPTION_NOTIFY_ON_EXPIRE=True SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 -# Referral Bonus Days +# Referral Bonuses (Inviter) REFERRAL_BONUS_DAYS_1_MONTH=3 REFERRAL_BONUS_DAYS_3_MONTHS=7 REFERRAL_BONUS_DAYS_6_MONTHS=15 REFERRAL_BONUS_DAYS_12_MONTHS=30 + +# Referral Bonuses (Referee) REFEREE_BONUS_DAYS_1_MONTH=1 REFEREE_BONUS_DAYS_3_MONTHS=3 REFEREE_BONUS_DAYS_6_MONTHS=7 REFEREE_BONUS_DAYS_12_MONTHS=15 -# Panel API Configuration -PANEL_API_URL=http://your_panel_api_url/api -PANEL_API_KEY=your_panel_api_key -PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures - -# User traffic limits (applied for all users) -# 0 means unlimited +# Panel Integration +PANEL_API_URL= +PANEL_API_KEY= USER_TRAFFIC_LIMIT_GB=0 -USER_TRAFFIC_STRATEGY="NO_RESET" +USER_TRAFFIC_STRATEGY=NO_RESET +USER_SQUAD_UUIDS= -# Default Internal Squads for Users (Optional, comma-separated UUIDs) -USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 - -# Trial Settings +# Trial Period TRIAL_ENABLED=True -TRIAL_DURATION_DAYS=5 -TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited -TRIAL_TRAFFIC_STRATEGY="NO_RESET" +TRIAL_DURATION_DAYS=3 +TRIAL_TRAFFIC_LIMIT_GB=5.0 -# Web Server Settings (for handling webhooks) -WEB_SERVER_HOST="0.0.0.0" +# Web Server +WEB_SERVER_HOST=0.0.0.0 WEB_SERVER_PORT=8080 - -# Admin Panel Log Pagination LOGS_PAGE_SIZE=10 + +# Mini App +SUBSCRIPTION_MINI_APP_URL= + +# Customization +START_COMMAND_DESCRIPTION= + +# Logging +LOG_CHAT_ID= +LOG_THREAD_ID= +LOG_NEW_USERS=True +LOG_PAYMENTS=True +LOG_PROMO_ACTIVATIONS=True +LOG_TRIAL_ACTIVATIONS=True +LOG_SUSPICIOUS_ACTIVITY=True + +# Inline Thumbnails +INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png +INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png +INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png +INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index ee9e963..f367acd 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -188,6 +188,37 @@ class NotificationService: # Send to log channel await self._send_to_log_channel(message) + + async def notify_suspicious_promo_attempt( + self, user_id: int, suspicious_input: str, + username: Optional[str] = None, first_name: Optional[str] = None): + """Send notification about a suspicious promo code attempt.""" + if not self.settings.LOG_SUSPICIOUS_ACTIVITY: + return + + admin_lang = self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext( + admin_lang, k, **kw) if self.i18n else k + + user_display = first_name or f"ID {user_id}" + if username: + user_display += f" (@{username})" + + message = _( + "log_suspicious_promo", + default="⚠️ Подозрительная попытка ввода промокода\n\n" + "👤 Пользователь: {user_display}\n" + "🆔 ID: {user_id}\n" + "📝 Ввод:
{suspicious_input}
\n" + "🕐 Время: {timestamp}", + user_display=hd.quote(user_display), + user_id=user_id, + suspicious_input=hd.quote(suspicious_input), + timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")) + + # Send to log channel and admins + await self._send_to_log_channel(message) + await self._send_to_admins(message) async def send_custom_notification(self, message: str, to_admins: bool = False, to_log_channel: bool = True, thread_id: Optional[int] = None): diff --git a/locales/en.json b/locales/en.json index a691568..4729f8a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -271,10 +271,6 @@ "admin_user_logs_error": "❌ Error loading user actions", "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", - - "inline_referral_title": "🎁 Invite a Friend", - "inline_referral_description": "Share referral link to get bonuses", - "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", "inline_admin_user_stats_title": "👥 User Statistics", "inline_admin_user_stats_desc": "Total: {total}, Active: {paid}", @@ -300,7 +296,8 @@ "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", - + "log_suspicious_promo": "⚠️ Suspicious Promo Code Attempt\n\n👤 User: {user_display}\n🆔 ID: {user_id}\n📝 Input:
{suspicious_input}
\n🕐 Time: {timestamp}", + "admin_general_cancel_operation": "Operation cancelled ❌", "admin_promo_codes_menu_bulk_create": "📦 Bulk Creation", "admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\nquantity|days|description\n\nExample:\n10|7|Weekly promo", @@ -317,7 +314,6 @@ "admin_promo_edit_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", "admin_promo_updated_success": "✅ Promo code updated!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", "admin_promo_update_failed": "❌ Error updating promo code", - "admin_promo_invalid_validity_days": "❌ Validity days must be from 1 to 365", "admin_logs_export_csv": "📄 Export to CSV", "admin_logs_csv_export_started": "📄 Starting log export to CSV...", @@ -338,7 +334,6 @@ "admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}", "admin_panel_stats_header": "Panel Statistics", - "admin_bulk_promo_invalid_values": "❌ Invalid values: {error}", "admin_bulk_promo_step1_quantity": "📦 Bulk Promo Code Creation\n\nStep 1 of 4: Quantity\n\nEnter the number of promo codes to create (1-1000):", "admin_bulk_promo_step2_bonus_days": "📦 Bulk Promo Code Creation\n\nStep 2 of 4: Bonus Days\n\nQuantity: {quantity}\n\nEnter the number of bonus days (1-365):", @@ -348,7 +343,6 @@ "admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000", "admin_bulk_promo_invalid_bonus_days": "❌ Bonus days must be between 1 and 365", "admin_bulk_promo_invalid_max_activations": "❌ Max activations must be between 1 and 10000", - "admin_bulk_promo_invalid_validity_days": "❌ Validity days must be between 1 and 365", "admin_bulk_promo_unlimited_validity": "♾️ Unlimited", "admin_bulk_promo_set_validity": "⏰ Set Period", @@ -361,7 +355,6 @@ "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", - "admin_promo_invalid_code_format": "❌ Promo code must contain 3-30 characters (letters and numbers only)", "admin_promo_code_already_exists": "❌ A promo code with this code already exists", "admin_promo_processing_error": "❌ Error processing promo code", "admin_promo_unlimited_validity": "♾️ Unlimited", @@ -370,8 +363,6 @@ "admin_promo_skip_description": "⏭️ Skip", "admin_promo_description_too_long": "❌ Description must not exceed 200 characters", "admin_promo_creating": "⏳ Creating promo code...", - "admin_promo_created_success": "✅ Promo code created!\n\n🎟 Code: {code}\n🎁 Bonus days: {days}\n🔢 Max activations: {max_act}\n⏰ Valid until: {validity}", - "admin_promo_creation_failed": "❌ Error creating promo code: {error}", "admin_panel_back_button": "⬅️ Back", "admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}", @@ -401,8 +392,6 @@ "admin_user_subscription_expired": "Expired {end_date}", "admin_user_subscription_none": "No active subscription", - "admin_user_logs_title": "📋 User logs {user_id}", - "admin_user_logs_empty": "No logs available", "admin_user_logs_count": "Total entries: {count}", "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", @@ -412,9 +401,6 @@ "admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code", - "admin_suspicious_promo_attempt_notification": "⚠️ Suspicious promo code attempt\n\n👤 User: @{user_username} (ID: {user_id})\n📝 Name: {user_first_name}\n💬 Input text: {promo_code_input}", - "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Suspicious promo code attempt\n\n👤 User ID: {user_id}\n📝 Name: {user_first_name}\n💬 Input text: {promo_code_input}", - "admin_promo_management_button": "🎟 Promo Management", "admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:", "admin_promo_management_empty": "📭 No promo codes available", @@ -429,13 +415,10 @@ "admin_promo_status_inactive": "🚫 Inactive", "admin_promo_status_expired": "⏰ Expired", "admin_promo_status_used_up": "🔄 Used up", - "admin_promo_edit_button": "✏️ Edit", "admin_promo_toggle_status_button": "🔄 On/Off", - "admin_promo_delete_button": "🗑 Delete", "admin_promo_view_activations_button": "📋 Activations", "admin_promo_back_to_list_button": "⬅️ Back to list", - "admin_panel_stats_header": "Panel Statistics", "admin_panel_online_label": "Online", "admin_panel_active_label": "Active", "admin_panel_disabled_label": "Disabled", @@ -466,8 +449,6 @@ "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", "inline_admin_help_title": "Commands help", "inline_admin_help_description": "Available commands: ref, stat, financial, system", - "inline_user_help_title": "Commands help", - "inline_user_help_description": "Available command: ref (referral link)", "admin_user_stats_total_label": "Total", "admin_user_stats_paid_subs_label": "With paid subscription", diff --git a/locales/ru.json b/locales/ru.json index d87ecfa..2d1c7dd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -271,10 +271,6 @@ "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", - - "inline_referral_title": "🎁 Пригласить друга", - "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", - "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", "inline_user_stats_description": "Всего: {total}, Платных: {active}", "inline_admin_user_stats_title": "👥 Статистика пользователей", @@ -289,8 +285,6 @@ "inline_admin_system_stats_desc": "Онлайн: {online}, Офлайн: {offline}", "inline_admin_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n👥 стат/stat - статистика пользователей\n💰 финансы - финансовая статистика\n🖥 система - системная статистика\n\n💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!", - "inline_admin_help_title": "🤖 Inline помощь (Админ)", - "inline_admin_help_desc": "Доступны команды: реф, стат, финансы, система", "inline_user_help_message": "🤖 Inline режим бота\n\n📱 Доступные команды:\n\n🎁 реф/ref - поделиться реферальной ссылкой\n\n💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!", "inline_user_help_title": "🤖 Inline помощь", @@ -301,6 +295,7 @@ "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", + "log_suspicious_promo": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь: {user_display}\n🆔 ID: {user_id}\n📝 Ввод:
{suspicious_input}
\n🕐 Время: {timestamp}", "admin_general_cancel_operation": "Операция отменена ❌", "admin_promo_codes_menu_bulk_create": "📦 Массовое создание", @@ -318,7 +313,6 @@ "admin_promo_edit_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", "admin_promo_updated_success": "✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", "admin_promo_update_failed": "❌ Ошибка обновления промокода", - "admin_promo_invalid_validity_days": "❌ Дни действия должны быть от 1 до 365", "admin_logs_export_csv": "📄 Экспорт в CSV", "admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...", @@ -339,7 +333,6 @@ "admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}", "admin_panel_stats_header": "Статистика панели", - "admin_bulk_promo_invalid_values": "❌ Неверные значения: {error}", "admin_bulk_promo_step1_quantity": "📦 Массовое создание промокодов\n\nШаг 1 из 4: Количество промокодов\n\nВведите количество промокодов для создания (1-1000):", "admin_bulk_promo_step2_bonus_days": "📦 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней (1-365):", @@ -349,7 +342,6 @@ "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", "admin_bulk_promo_invalid_bonus_days": "❌ Бонусные дни должны быть от 1 до 365", "admin_bulk_promo_invalid_max_activations": "❌ Максимальные активации должны быть от 1 до 10000", - "admin_bulk_promo_invalid_validity_days": "❌ Дни действия должны быть от 1 до 365", "admin_bulk_promo_unlimited_validity": "♾️ Неограниченно", "admin_bulk_promo_set_validity": "⏰ Установить срок", @@ -362,7 +354,6 @@ "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", - "admin_promo_invalid_code_format": "❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", "admin_promo_processing_error": "❌ Ошибка обработки промокода", "admin_promo_unlimited_validity": "♾️ Неограниченно", @@ -371,8 +362,6 @@ "admin_promo_skip_description": "⏭️ Пропустить", "admin_promo_description_too_long": "❌ Описание не должно превышать 200 символов", "admin_promo_creating": "⏳ Создаю промокод...", - "admin_promo_created_success": "✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", - "admin_promo_creation_failed": "❌ Ошибка создания промокода: {error}", "admin_panel_back_button": "⬅️ Назад", "admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}", @@ -402,8 +391,6 @@ "admin_user_subscription_expired": "Истекла {end_date}", "admin_user_subscription_none": "Нет активной подписки", - "admin_user_logs_title": "📋 Логи пользователя {user_id}", - "admin_user_logs_empty": "Логи отсутствуют", "admin_user_logs_count": "Всего записей: {count}", "admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}", @@ -413,9 +400,6 @@ "admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод", - "admin_suspicious_promo_attempt_notification": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь: @{user_username} (ID: {user_id})\n📝 Имя: {user_first_name}\n💬 Введенный текст: {promo_code_input}", - "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь ID: {user_id}\n📝 Имя: {user_first_name}\n💬 Введенный текст: {promo_code_input}", - "admin_promo_management_button": "🎟 Управление промокодами", "admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:", "admin_promo_management_empty": "📭 Промокоды отсутствуют", @@ -430,13 +414,10 @@ "admin_promo_status_inactive": "🚫 Неактивен", "admin_promo_status_expired": "⏰ Истек", "admin_promo_status_used_up": "🔄 Исчерпан", - "admin_promo_edit_button": "✏️ Редактировать", "admin_promo_toggle_status_button": "🔄 Вкл/Выкл", - "admin_promo_delete_button": "🗑 Удалить", "admin_promo_view_activations_button": "📋 Активации", "admin_promo_back_to_list_button": "⬅️ К списку", - "admin_panel_stats_header": "Статистика панели", "admin_panel_online_label": "Онлайн", "admin_panel_active_label": "Активных", "admin_panel_disabled_label": "Отключенных", @@ -467,8 +448,6 @@ "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", "inline_admin_help_title": "Помощь по командам", "inline_admin_help_description": "Доступны команды: реф, стат, финансы, система", - "inline_user_help_title": "Помощь по командам", - "inline_user_help_description": "Доступна команда: реф (реферальная ссылка)", "admin_user_stats_total_label": "Всего", "admin_user_stats_paid_subs_label": "С платной подпиской", From ad8f05e20bff1fe371fad93fba1aaa50a5dac303 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Mon, 4 Aug 2025 14:32:53 +0300 Subject: [PATCH 18/26] Refactor notification service to streamline admin notifications - Removed the functionality to send suspicious promo attempt notifications to admins, simplifying the notification process. - Updated comments in the code to reflect the changes made in the notification handling logic. --- bot/services/notification_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index f367acd..15af8e3 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -216,9 +216,8 @@ class NotificationService: suspicious_input=hd.quote(suspicious_input), timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z")) - # Send to log channel and admins + # Send to log channel await self._send_to_log_channel(message) - await self._send_to_admins(message) async def send_custom_notification(self, message: str, to_admins: bool = False, to_log_channel: bool = True, thread_id: Optional[int] = None): From c8831718edf3490e3d671cbf88169c171781e567 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Mon, 4 Aug 2025 18:10:33 +0300 Subject: [PATCH 19/26] Add promo code toggle and activations handling in admin panel - Implemented a new handler to toggle the active status of promo codes, allowing admins to activate or deactivate codes easily. - Added functionality to display activations for specific promo codes, enhancing visibility into their usage. - Updated localization files to include new messages related to promo status changes and activations in both English and Russian, improving admin experience. --- bot/handlers/admin/promo_codes.py | 104 ++++++++++++++++++++++++++++++ locales/en.json | 8 +++ locales/ru.json | 8 +++ 3 files changed, 120 insertions(+) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index a7ffceb..623624c 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1016,6 +1016,110 @@ async def process_promo_edit_validity_handler(message: types.Message, state: FSM )) +@router.callback_query(F.data.startswith("promo_toggle:")) +async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Toggle promo code active status""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + if not promo: + await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) + return + + # Toggle status + new_status = not promo.is_active + update_data = {"is_active": new_status} + + updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if updated: + await session.commit() + + status_text = _("admin_promo_status_activated", default="активирован") if new_status else _("admin_promo_status_deactivated", default="деактивирован") + success_text = _( + "admin_promo_toggle_success", + default="✅ Промокод {code} {status}", + code=promo.code, + status=status_text + ) + + await callback.answer(success_text, show_alert=True) + + # Refresh the detail view + await promo_detail_handler(callback, i18n_data, settings, session) + else: + await session.rollback() + await callback.answer(_( + "admin_promo_toggle_failed", + default="❌ Ошибка изменения статуса промокода" + ), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_activations:")) +async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Show promo code activations""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + if not promo: + await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) + return + + # Get activations for this promo code + activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) + + if not activations: + text = _( + "admin_promo_no_activations", + default="📋 Активации промокода: {code}\n\n❌ Активаций не найдено", + code=promo.code + ) + else: + text_parts = [_( + "admin_promo_activations_header", + default="📋 Активации промокода: {code}\n\n", + code=promo.code + )] + + for activation in activations: + activation_date = activation.created_at.strftime('%Y-%m-%d %H:%M') if activation.created_at else "N/A" + text_parts.append(_( + "admin_promo_activation_item", + default="👤 User ID: {user_id}\n📅 Дата: {date}\n", + user_id=activation.user_id, + date=activation_date + )) + + text = "".join(text_parts) + + # Build keyboard with back button + kb = InlineKeyboardBuilder() + kb.row( + InlineKeyboardButton( + text=_("admin_promo_back_to_detail_button", default="⬅️ К промокоду"), + callback_data=f"promo_detail:{promo_id}") + ) + + await callback.message.edit_text( + text, + reply_markup=kb.as_markup(), + parse_mode="HTML") + await callback.answer() + + @router.callback_query(F.data.startswith("promo_delete:")) async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): diff --git a/locales/en.json b/locales/en.json index 4729f8a..ef6d71b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -413,11 +413,19 @@ "admin_promo_card_created_by": "👤 Created by: {creator}", "admin_promo_status_active": "✅ Active", "admin_promo_status_inactive": "🚫 Inactive", + "admin_promo_status_activated": "activated", + "admin_promo_status_deactivated": "deactivated", "admin_promo_status_expired": "⏰ Expired", "admin_promo_status_used_up": "🔄 Used up", "admin_promo_toggle_status_button": "🔄 On/Off", "admin_promo_view_activations_button": "📋 Activations", "admin_promo_back_to_list_button": "⬅️ Back to list", + "admin_promo_toggle_success": "✅ Promo code {code} {status}", + "admin_promo_toggle_failed": "❌ Error changing promo code status", + "admin_promo_no_activations": "📋 Activations of promo code: {code}\n\n❌ No activations found", + "admin_promo_activations_header": "📋 Activations of promo code: {code}\n\n", + "admin_promo_activation_item": "👤 User ID: {user_id}\n📅 Date: {date}\n", + "admin_promo_back_to_detail_button": "⬅️ Back to promo", "admin_panel_online_label": "Online", "admin_panel_active_label": "Active", diff --git a/locales/ru.json b/locales/ru.json index 2d1c7dd..086d864 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -412,11 +412,19 @@ "admin_promo_card_created_by": "👤 Создал: {creator}", "admin_promo_status_active": "✅ Активен", "admin_promo_status_inactive": "🚫 Неактивен", + "admin_promo_status_activated": "активирован", + "admin_promo_status_deactivated": "деактивирован", "admin_promo_status_expired": "⏰ Истек", "admin_promo_status_used_up": "🔄 Исчерпан", "admin_promo_toggle_status_button": "🔄 Вкл/Выкл", "admin_promo_view_activations_button": "📋 Активации", "admin_promo_back_to_list_button": "⬅️ К списку", + "admin_promo_toggle_success": "✅ Промокод {code} {status}", + "admin_promo_toggle_failed": "❌ Ошибка изменения статуса промокода", + "admin_promo_no_activations": "📋 Активации промокода: {code}\n\n❌ Активаций не найдено", + "admin_promo_activations_header": "📋 Активации промокода: {code}\n\n", + "admin_promo_activation_item": "👤 User ID: {user_id}\n📅 Дата: {date}\n", + "admin_promo_back_to_detail_button": "⬅️ К промокоду", "admin_panel_online_label": "Онлайн", "admin_panel_active_label": "Активных", From 74719669fbc50c273a0a982e7a4529a5da82e2c2 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Mon, 4 Aug 2025 18:16:59 +0300 Subject: [PATCH 20/26] Fix activation date reference and update localization for promo code toggle success message - Changed the reference from `created_at` to `activated_at` in the promo activations handler to accurately reflect the activation date. - Updated the localization files for both English and Russian to remove HTML tags from the promo code toggle success message, enhancing clarity. --- bot/handlers/admin/promo_codes.py | 2 +- db/dal/promo_code_dal.py | 2 +- locales/en.json | 2 +- locales/ru.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 623624c..525d495 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -1095,7 +1095,7 @@ async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: di )] for activation in activations: - activation_date = activation.created_at.strftime('%Y-%m-%d %H:%M') if activation.created_at else "N/A" + activation_date = activation.activated_at.strftime('%Y-%m-%d %H:%M') if activation.activated_at else "N/A" text_parts.append(_( "admin_promo_activation_item", default="👤 User ID: {user_id}\n📅 Дата: {date}\n", diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index f937b4d..5d1f017 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -77,7 +77,7 @@ async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: from db.models import PromoCodeActivation stmt = (select(PromoCodeActivation) .where(PromoCodeActivation.promo_code_id == promo_code_id) - .order_by(PromoCodeActivation.created_at.desc()) + .order_by(PromoCodeActivation.activated_at.desc()) .limit(50)) # Limit to last 50 activations result = await session.execute(stmt) return result.scalars().all() diff --git a/locales/en.json b/locales/en.json index ef6d71b..3226fd0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -420,7 +420,7 @@ "admin_promo_toggle_status_button": "🔄 On/Off", "admin_promo_view_activations_button": "📋 Activations", "admin_promo_back_to_list_button": "⬅️ Back to list", - "admin_promo_toggle_success": "✅ Promo code {code} {status}", + "admin_promo_toggle_success": "✅ Promo code {code} {status}", "admin_promo_toggle_failed": "❌ Error changing promo code status", "admin_promo_no_activations": "📋 Activations of promo code: {code}\n\n❌ No activations found", "admin_promo_activations_header": "📋 Activations of promo code: {code}\n\n", diff --git a/locales/ru.json b/locales/ru.json index 086d864..2f1cb06 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -419,7 +419,7 @@ "admin_promo_toggle_status_button": "🔄 Вкл/Выкл", "admin_promo_view_activations_button": "📋 Активации", "admin_promo_back_to_list_button": "⬅️ К списку", - "admin_promo_toggle_success": "✅ Промокод {code} {status}", + "admin_promo_toggle_success": "✅ Промокод {code} {status}", "admin_promo_toggle_failed": "❌ Ошибка изменения статуса промокода", "admin_promo_no_activations": "📋 Активации промокода: {code}\n\n❌ Активаций не найдено", "admin_promo_activations_header": "📋 Активации промокода: {code}\n\n", From c5f41a261b79b732853880eb9b7cd89233826f91 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 14:43:19 +0300 Subject: [PATCH 21/26] Update promo code success message and enhance subscription service settings - Revised the success message for promo code creation to include updated variable names for clarity in the response. - Added a new setting to control notification behavior for Tribute payments, improving customization options for subscription management. - Updated Russian localization for admin panel buttons to enhance clarity and consistency in user interface elements. --- bot/handlers/admin/promo_codes.py | 8 ++++---- bot/services/subscription_service.py | 2 +- config/settings.py | 1 + locales/ru.json | 10 +++++----- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py index 525d495..aacf286 100644 --- a/bot/handlers/admin/promo_codes.py +++ b/bot/handlers/admin/promo_codes.py @@ -369,11 +369,11 @@ async def create_promo_code_final(callback_or_message, # Send success message success_text = _( "admin_promo_created_success", - default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", + default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {bonus_days}\n🔢 Максимальные активации: {max_activations}\n⏰ Действует до: {valid_until_str}", code=promo_code, - days=bonus_days, - max_act=max_activations, - validity=valid_until_str_display + bonus_days=bonus_days, + max_activations=max_activations, + valid_until_str=valid_until_str_display ) if hasattr(callback_or_message, 'message'): diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index ffcb678..0be251b 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -482,7 +482,7 @@ class SubscriptionService: "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, - "skip_notifications": provider == "tribute", + "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, } try: new_or_updated_sub = await subscription_dal.upsert_subscription( diff --git a/config/settings.py b/config/settings.py index ac6f649..8b7a07a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -66,6 +66,7 @@ class Settings(BaseSettings): TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None) TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None) TRIBUTE_API_KEY: Optional[str] = Field(default=None) + TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments") PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) diff --git a/locales/ru.json b/locales/ru.json index 2f1cb06..a0389c8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -244,12 +244,12 @@ "admin_user_subscription_info": "Информация о подписке:", "admin_user_reset_trial_button": "🔄 Сбросить триал", "admin_user_add_subscription_button": "➕ Добавить дни", - "admin_user_toggle_ban_button": "🚫 Заблокировать/Разблокировать", - "admin_user_send_message_button": "✉️ Отправить сообщение", - "admin_user_view_logs_button": "📜 Действия пользователя", + "admin_user_toggle_ban_button": "🚫 Бан/Разбан", + "admin_user_send_message_button": "✉️ Сообщение", + "admin_user_view_logs_button": "📜 Логи", "admin_user_refresh_button": "🔄 Обновить", - "admin_user_search_new_button": "🔍 Найти другого", - "admin_user_view_all_logs_button": "📋 Все действия", + "admin_user_search_new_button": "🔍 Новый поиск", + "admin_user_view_all_logs_button": "📋 Все логи", "admin_user_back_to_card_button": "🔙 К карточке", "admin_user_not_found": "❌ Пользователь не найден: {input}", From 9cf5a93625a0dbaa77b2bff0c7f9770eeab9cc0e Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 14:55:55 +0300 Subject: [PATCH 22/26] Refactor promo code handling in admin panel and clean up unused methods - Replaced the promo_codes module with a new promo management structure, enhancing organization and clarity in the admin panel. - Updated admin panel handlers to utilize the new promo management methods, improving the flow of promo code creation and management. - Removed deprecated methods from payment and subscription data access layers, streamlining the codebase and improving maintainability. - Ensured localization files are updated to reflect changes in promo management messaging, enhancing user experience. --- bot/handlers/admin/__init__.py | 4 +- bot/handlers/admin/common.py | 14 +- bot/handlers/admin/promo/__init__.py | 13 + bot/handlers/admin/promo/bulk.py | 511 ++++++++ bot/handlers/admin/promo/create.py | 432 +++++++ bot/handlers/admin/promo/manage.py | 422 +++++++ bot/handlers/admin/promo_codes.py | 1611 -------------------------- db/dal/payment_dal.py | 49 - db/dal/subscription_dal.py | 64 - db/dal/user_dal.py | 58 - 10 files changed, 1388 insertions(+), 1790 deletions(-) create mode 100644 bot/handlers/admin/promo/__init__.py create mode 100644 bot/handlers/admin/promo/bulk.py create mode 100644 bot/handlers/admin/promo/create.py create mode 100644 bot/handlers/admin/promo/manage.py delete mode 100644 bot/handlers/admin/promo_codes.py diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index ddfe2bb..2444ab2 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -2,7 +2,7 @@ from aiogram import Router from . import common from . import broadcast -from . import promo_codes +from .promo import promo_router_aggregate from . import user_management from . import statistics from . import sync_admin @@ -12,7 +12,7 @@ admin_router_aggregate = Router(name="admin_features_router") admin_router_aggregate.include_router(common.router) admin_router_aggregate.include_router(broadcast.router) -admin_router_aggregate.include_router(promo_codes.router) +admin_router_aggregate.include_router(promo_router_aggregate) admin_router_aggregate.include_router(user_management.router) admin_router_aggregate.include_router(statistics.router) admin_router_aggregate.include_router(sync_admin.router) diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index f08e342..e051dd6 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -16,7 +16,9 @@ from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from . import broadcast as admin_broadcast_handlers -from . import promo_codes as admin_promo_handlers +from .promo import create as admin_promo_create_handlers +from .promo import manage as admin_promo_manage_handlers +from .promo import bulk as admin_promo_bulk_handlers from . import user_management as admin_user_mgmnt_handlers from . import statistics as admin_stats_handlers from . import sync_admin as admin_sync_handlers @@ -77,16 +79,16 @@ async def admin_panel_actions_callback_handler( await admin_broadcast_handlers.broadcast_message_prompt_handler( callback, state, i18n_data, settings, session) elif action == "create_promo": - await admin_promo_handlers.create_promo_prompt_handler( + await admin_promo_create_handlers.create_promo_prompt_handler( callback, state, i18n_data, settings, session) elif action == "create_bulk_promo": - await admin_promo_handlers.create_bulk_promo_prompt_handler( + await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler( callback, state, i18n_data, settings, session) elif action == "manage_promos": - await admin_promo_handlers.manage_promo_codes_handler( + await admin_promo_manage_handlers.manage_promo_codes_handler( callback, i18n_data, settings, session) elif action == "view_promos": - await admin_promo_handlers.view_promo_codes_handler( + await admin_promo_manage_handlers.view_promo_codes_handler( callback, i18n_data, settings, session) elif action == "ban_user_prompt": await admin_user_mgmnt_handlers.ban_user_prompt_handler( @@ -106,7 +108,7 @@ async def admin_panel_actions_callback_handler( await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session) elif action == "promo_management": - await admin_promo_handlers.promo_management_handler( + await admin_promo_manage_handlers.promo_management_handler( callback, i18n_data, settings, session) elif action == "sync_panel": diff --git a/bot/handlers/admin/promo/__init__.py b/bot/handlers/admin/promo/__init__.py new file mode 100644 index 0000000..aeca5f9 --- /dev/null +++ b/bot/handlers/admin/promo/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router + +from . import create +from . import manage +from . import bulk + +promo_router_aggregate = Router(name="promo_features_router") + +promo_router_aggregate.include_router(create.router) +promo_router_aggregate.include_router(manage.router) +promo_router_aggregate.include_router(bulk.router) + +__all__ = ("promo_router_aggregate",) \ No newline at end of file diff --git a/bot/handlers/admin/promo/bulk.py b/bot/handlers/admin/promo/bulk.py new file mode 100644 index 0000000..8a2f7c7 --- /dev/null +++ b/bot/handlers/admin/promo/bulk.py @@ -0,0 +1,511 @@ +import logging +import random +import string +from aiogram import Router, F, types +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import promo_code_dal +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_bulk_router") + + +async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing bulk promo creation.", + show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + # Step 1: Ask for quantity + prompt_text = _( + "admin_bulk_promo_step1_quantity", + default="🎟 Массовое создание промокодов\n\nШаг 1 из 4: Количество\n\nВведите количество промокодов для создания (1-100):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + except Exception as e: + logging.warning( + f"Could not edit message for bulk promo prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + await callback.answer() + await state.set_state(AdminStates.waiting_for_bulk_promo_quantity) + + +def generate_unique_promo_code(length: int = 8) -> str: + """Generate a unique random promo code""" + characters = string.ascii_uppercase + string.digits + return ''.join(random.choice(characters) for _ in range(length)) + + +# Step 1: Process quantity +@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text) +async def process_bulk_promo_quantity_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + quantity = int(message.text.strip()) + if not (1 <= quantity <= 100): + await message.answer(_( + "admin_bulk_promo_invalid_quantity", + default="❌ Количество промокодов должно быть от 1 до 100" + )) + return + + await state.update_data(quantity=quantity) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_bulk_promo_step2_bonus_days", + default="🎟 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней для каждого промокода (1-365):", + quantity=quantity + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo quantity: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text) +async def process_bulk_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + bonus_days = int(message.text.strip()) + if not (1 <= bonus_days <= 365): + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество бонусных дней должно быть от 1 до 365" + )) + return + + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_step3_max_activations", + default="🎟 Массовое создание промокодов\n\nШаг 3 из 4: Лимит активаций\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", + quantity=data.get("quantity"), + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo bonus days: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text) +async def process_bulk_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if not (1 <= max_activations <= 10000): + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальное количество активаций должно быть от 1 до 10000" + )) + return + + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_step4_validity", + default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокодов:", + quantity=data.get("quantity"), + bonus_days=data.get("bonus_days"), + max_activations=max_activations + ) + + # Create keyboard for validity options + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + callback_data="bulk_promo_unlimited_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + callback_data="bulk_promo_set_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + await message.answer( + prompt_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo max activations: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 4: Handle unlimited validity +@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(validity_days=None) + await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session) + + +# Step 4: Handle set validity +@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) +async def process_bulk_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing validity.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + prompt_text = _( + "admin_bulk_promo_enter_validity_days", + default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокодов (1-365):", + quantity=data.get("quantity"), + bonus_days=data.get("bonus_days"), + max_activations=data.get("max_activations") + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + +# Step 4: Process validity days +@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text) +async def process_bulk_promo_validity_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if not (1 <= validity_days <= 365): + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 1 до 365 дней" + )) + return + + await state.update_data(validity_days=validity_days) + await create_bulk_promo_codes_final(message, state, i18n_data, settings, session) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing bulk promo validity days: {e}") + await message.answer(_("error_occurred_try_again")) + + +async def create_bulk_promo_codes_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step - create multiple promo codes in database""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + data = await state.get_data() + quantity = data["quantity"] + + # Show progress message + progress_text = _( + "admin_bulk_promo_creating", + default="🔄 Создание {quantity} промокодов...", + quantity=quantity + ) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text(progress_text, parse_mode="HTML") + except Exception: + await callback_or_message.message.answer(progress_text, parse_mode="HTML") + await callback_or_message.answer() + else: # Message + await callback_or_message.answer(progress_text, parse_mode="HTML") + + # Generate and create promo codes + created_codes = [] + failed_codes = [] + + for i in range(quantity): + try: + # Generate unique code + attempts = 0 + while attempts < 10: # Max 10 attempts to generate unique code + promo_code = generate_unique_promo_code() + existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) + if not existing_promo: + break + attempts += 1 + + if attempts >= 10: + failed_codes.append(f"Код #{i+1} (не удалось сгенерировать уникальный)") + continue + + # Prepare promo code data + promo_data = { + "code": promo_code, + "bonus_days": data["bonus_days"], + "max_activations": data["max_activations"], + "current_activations": 0, + "is_active": True, + "created_by_admin_id": callback_or_message.from_user.id, + "created_at": datetime.now(timezone.utc) + } + + # Set validity + if data.get("validity_days"): + promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) + else: + promo_data["valid_until"] = None + + # Create promo code + created_promo = await promo_code_dal.create_promo_code(session, promo_data) + created_codes.append(created_promo.code) + + except Exception as e: + logging.error(f"Error creating bulk promo code #{i+1}: {e}") + failed_codes.append(f"Код #{i+1} ({str(e)[:50]})") + + await session.commit() + + # Success message + success_lines = [ + _( + "admin_bulk_promo_created_title", + default="✅ Массовое создание завершено!\n" + ), + _( + "admin_bulk_promo_created_stats", + default="📊 Создано: {created} из {total}", + created=len(created_codes), + total=quantity + ) + ] + + if data.get("validity_days"): + validity_text = f"{data['validity_days']} дней" + else: + validity_text = _("admin_promo_unlimited", default="Без ограничений") + + success_lines.append( + _( + "admin_bulk_promo_settings", + default="🎁 Бонусные дни: {bonus_days}\n" + "📊 Макс. активаций: {max_activations}\n" + "⏰ Срок действия: {validity}", + bonus_days=data["bonus_days"], + max_activations=data["max_activations"], + validity=validity_text + ) + ) + + if created_codes: + success_lines.append("\n🎟 Созданные коды:") + # Show first 20 codes, then indicate if there are more + codes_to_show = created_codes[:20] + for code in codes_to_show: + success_lines.append(f"{code}") + + if len(created_codes) > 20: + success_lines.append(f"... и еще {len(created_codes) - 20} кодов") + + if failed_codes: + success_lines.append(f"\n❌ Ошибки ({len(failed_codes)}):") + for error in failed_codes[:5]: # Show first 5 errors + success_lines.append(error) + if len(failed_codes) > 5: + success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок") + + success_text = "\n".join(success_lines) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback_or_message.message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + else: # Message + await callback_or_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logging.error(f"Error creating bulk promo codes: {e}") + error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + + if hasattr(callback_or_message, 'message'): # CallbackQuery + await callback_or_message.message.answer(error_text) + else: # Message + await callback_or_message.answer(error_text) + + await state.clear() + + +# Cancel bulk promo creation +@router.callback_query( + F.data == "admin_action:main", + StateFilter( + AdminStates.waiting_for_bulk_promo_quantity, + AdminStates.waiting_for_bulk_promo_bonus_days, + AdminStates.waiting_for_bulk_promo_max_activations, + AdminStates.waiting_for_bulk_promo_validity_days, + ), +) +async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error cancelling.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + await callback.message.edit_text( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + except Exception: + await callback.message.answer( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + + await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено")) + await state.clear() \ No newline at end of file diff --git a/bot/handlers/admin/promo/create.py b/bot/handlers/admin/promo/create.py new file mode 100644 index 0000000..7c7137f --- /dev/null +++ b/bot/handlers/admin/promo/create.py @@ -0,0 +1,432 @@ +import logging +from aiogram import Router, F, types +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import promo_code_dal +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_create_router") + + +async def create_promo_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing promo creation.", + show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + # Step 1: Ask for promo code + prompt_text = _( + "admin_promo_step1_code", + default="🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + except Exception as e: + logging.warning( + f"Could not edit message for promo prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML") + await callback.answer() + await state.set_state(AdminStates.waiting_for_promo_code) + + +# Step 1: Process promo code +@router.message(AdminStates.waiting_for_promo_code, F.text) +async def process_promo_code_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + code_str = message.text.strip().upper() + if not (3 <= len(code_str) <= 30 and code_str.isalnum()): + await message.answer(_( + "admin_promo_invalid_code_format", + default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + )) + return + + # Check if code already exists + existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) + if existing_promo: + await message.answer(_( + "admin_promo_code_already_exists", + default="❌ Промокод с таким кодом уже существует" + )) + return + + await state.update_data(promo_code=code_str) + + # Step 2: Ask for bonus days + prompt_text = _( + "admin_promo_step2_bonus_days", + default="🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + code=code_str + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_bonus_days) + + except Exception as e: + logging.error(f"Error processing promo code: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 2: Process bonus days +@router.message(AdminStates.waiting_for_promo_bonus_days, F.text) +async def process_promo_bonus_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + bonus_days = int(message.text.strip()) + if not (1 <= bonus_days <= 365): + await message.answer(_( + "admin_promo_invalid_bonus_days", + default="❌ Количество бонусных дней должно быть от 1 до 365" + )) + return + + await state.update_data(bonus_days=bonus_days) + + # Step 3: Ask for max activations + data = await state.get_data() + prompt_text = _( + "admin_promo_step3_max_activations", + default="🎟 Создание промокода\n\nШаг 3 из 4: Лимит активаций\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + code=data.get("promo_code"), + bonus_days=bonus_days + ) + + await message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_max_activations) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo bonus days: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 3: Process max activations +@router.message(AdminStates.waiting_for_promo_max_activations, F.text) +async def process_promo_max_activations_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + max_activations = int(message.text.strip()) + if not (1 <= max_activations <= 10000): + await message.answer(_( + "admin_promo_invalid_max_activations", + default="❌ Максимальное количество активаций должно быть от 1 до 10000" + )) + return + + await state.update_data(max_activations=max_activations) + + # Step 4: Ask for validity + data = await state.get_data() + prompt_text = _( + "admin_promo_step4_validity", + default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокода:", + code=data.get("promo_code"), + bonus_days=data.get("bonus_days"), + max_activations=max_activations + ) + + # Create keyboard for validity options + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + callback_data="promo_unlimited_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + callback_data="promo_set_validity" + ) + ) + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + await message.answer( + prompt_text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_promo_validity_days) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo max activations: {e}") + await message.answer(_("error_occurred_try_again")) + + +# Step 4: Handle unlimited validity +@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_unlimited_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + await state.update_data(validity_days=None) + await create_promo_code_final(callback, state, i18n_data, settings, session) + + +# Step 4: Handle set validity +@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) +async def process_promo_set_validity(callback: types.CallbackQuery, + state: FSMContext, + i18n_data: dict, + settings: Settings): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing validity.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + data = await state.get_data() + prompt_text = _( + "admin_promo_enter_validity_days", + default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокода (1-365):", + code=data.get("promo_code"), + bonus_days=data.get("bonus_days"), + max_activations=data.get("max_activations") + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + +# Step 4: Process validity days +@router.message(AdminStates.waiting_for_promo_validity_days, F.text) +async def process_promo_validity_days_handler(message: types.Message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + validity_days = int(message.text.strip()) + if not (1 <= validity_days <= 365): + await message.answer(_( + "admin_promo_invalid_validity_days", + default="❌ Срок действия должен быть от 1 до 365 дней" + )) + return + + await state.update_data(validity_days=validity_days) + await create_promo_code_final(message, state, i18n_data, settings, session) + + except ValueError: + await message.answer(_( + "admin_promo_invalid_number", + default="❌ Введите корректное число" + )) + except Exception as e: + logging.error(f"Error processing promo validity days: {e}") + await message.answer(_("error_occurred_try_again")) + + +async def create_promo_code_final(callback_or_message, + state: FSMContext, + i18n_data: dict, + settings: Settings, + session: AsyncSession): + """Final step - create the promo code in database""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + data = await state.get_data() + + # Prepare promo code data + promo_data = { + "code": data["promo_code"], + "bonus_days": data["bonus_days"], + "max_activations": data["max_activations"], + "current_activations": 0, + "is_active": True, + "created_by_admin_id": callback_or_message.from_user.id, + "created_at": datetime.now(timezone.utc) + } + + # Set validity + if data.get("validity_days"): + promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) + else: + promo_data["valid_until"] = None + + # Create promo code + created_promo = await promo_code_dal.create_promo_code(session, promo_data) + await session.commit() + + # Success message + success_text = _( + "admin_promo_created_success", + default="✅ Промокод успешно создан!\n\n" + "🎟 Код: {code}\n" + "🎁 Бонусные дни: {bonus_days}\n" + "📊 Макс. активаций: {max_activations}\n" + "⏰ Срок действия: {validity}", + code=data["promo_code"], + bonus_days=data["bonus_days"], + max_activations=data["max_activations"], + validity=_("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней" + ) + + if hasattr(callback_or_message, 'message'): # CallbackQuery + try: + await callback_or_message.message.edit_text( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback_or_message.message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback_or_message.answer() + else: # Message + await callback_or_message.answer( + success_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + + await state.clear() + + except Exception as e: + logging.error(f"Error creating promo code: {e}") + error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + + if hasattr(callback_or_message, 'message'): # CallbackQuery + await callback_or_message.message.answer(error_text) + await callback_or_message.answer() + else: # Message + await callback_or_message.answer(error_text) + + await state.clear() + + +# Cancel promo creation +@router.callback_query( + F.data == "admin_action:main", + StateFilter( + AdminStates.waiting_for_promo_code, + AdminStates.waiting_for_promo_bonus_days, + AdminStates.waiting_for_promo_max_activations, + AdminStates.waiting_for_promo_validity_days, + ), +) +async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, + state: FSMContext, + settings: Settings, + i18n_data: dict, + session: AsyncSession): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error cancelling.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + await callback.message.edit_text( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + except Exception: + await callback.message.answer( + _(key="admin_panel_title"), + reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) + ) + + await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено")) + await state.clear() \ No newline at end of file diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py new file mode 100644 index 0000000..1b00406 --- /dev/null +++ b/bot/handlers/admin/promo/manage.py @@ -0,0 +1,422 @@ +import logging +from aiogram import Router, F, types +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from datetime import datetime, timedelta, timezone +from typing import Optional, List +from sqlalchemy.ext.asyncio import AsyncSession + +from config.settings import Settings +from db.dal import promo_code_dal +from db.models import PromoCode, PromoCodeActivation +from bot.states.admin_states import AdminStates +from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from bot.middlewares.i18n import JsonI18n + +router = Router(name="promo_manage_router") + + +async def view_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """View all active promo codes""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_models = await promo_code_dal.get_all_active_promo_codes(session, + limit=20, + offset=0) + + if not promo_models: + text = _( + "admin_no_promo_codes", + default="📋 Активные промокоды\n\n❌ Нет активных промокодов" + ) + else: + lines = [_("admin_active_promo_codes_title", + default="📋 Активные промокоды\n")] + + for promo in promo_models: + validity_str = _("admin_promo_unlimited", default="Без ограничений") + if promo.valid_until: + validity_str = promo.valid_until.strftime("%d.%m.%Y") + + lines.append( + f"🎟 {promo.code} | " + f"🎁 {promo.bonus_days}д | " + f"📊 {promo.current_activations}/{promo.max_activations} | " + f"⏰ {validity_str}" + ) + + text = "\n".join(lines) + + try: + await callback.message.edit_text( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + + except Exception as e: + logging.error(f"Error viewing promo codes: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +async def promo_management_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Main promo management interface""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) + + if not promo_models: + text = _( + "admin_no_promo_codes_to_manage", + default="🛠 Управление промокодами\n\n❌ Нет промокодов для управления" + ) + await callback.message.edit_text( + text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + parse_mode="HTML" + ) + await callback.answer() + return + + builder = InlineKeyboardBuilder() + text_lines = [_("admin_promo_management_title", default="🛠 Управление промокодами\n")] + + for promo in promo_models: + status_emoji = "✅" if promo.is_active else "❌" + validity_str = _("admin_promo_unlimited", default="∞") + if promo.valid_until: + validity_str = promo.valid_until.strftime("%d.%m") + + text_lines.append( + f"{status_emoji} {promo.code} | " + f"🎁{promo.bonus_days}д | " + f"📊{promo.current_activations}/{promo.max_activations} | " + f"⏰{validity_str}" + ) + + builder.row( + InlineKeyboardButton( + text=f"📝 {promo.code}", + callback_data=f"promo_detail:{promo.promo_code_id}" + ) + ) + + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_panel", default="🔙 В админ панель"), + callback_data="admin_action:main" + ) + ) + + text = "\n".join(text_lines) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except Exception as e: + logging.error(f"Error in promo management: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_detail:")) +async def promo_detail_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Show detailed promo code information with management options""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + # Format promo details + status_text = _("admin_promo_active", default="✅ Активен") if promo.is_active else _("admin_promo_inactive", default="❌ Неактивен") + validity_text = _("admin_promo_unlimited", default="Без ограничений") + if promo.valid_until: + if promo.valid_until > datetime.now(timezone.utc): + validity_text = promo.valid_until.strftime("%d.%m.%Y %H:%M") + else: + validity_text = f"{promo.valid_until.strftime('%d.%m.%Y %H:%M')} (истек)" + + created_at = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" + + text = _( + "admin_promo_detail_info", + default="🎟 Детали промокода\n\n" + "📝 Код: {code}\n" + "🎁 Бонусные дни: {bonus_days}\n" + "📊 Использований: {current}/{max}\n" + "📋 Статус: {status}\n" + "⏰ Действует до: {validity}\n" + "👤 Создал: {admin_id}\n" + "🕐 Создан: {created}", + code=promo.code, + bonus_days=promo.bonus_days, + current=promo.current_activations, + max=promo.max_activations, + status=status_text, + validity=validity_text, + admin_id=promo.created_by_admin_id, + created=created_at + ) + + # Create management buttons + builder = InlineKeyboardBuilder() + + # Edit button + builder.row( + InlineKeyboardButton( + text=_("admin_promo_edit", default="✏️ Редактировать"), + callback_data=f"promo_edit:{promo_id}" + ) + ) + + # Toggle active/inactive + toggle_text = _("admin_promo_deactivate", default="❌ Деактивировать") if promo.is_active else _("admin_promo_activate", default="✅ Активировать") + builder.row( + InlineKeyboardButton( + text=toggle_text, + callback_data=f"promo_toggle:{promo_id}" + ) + ) + + # View activations + builder.row( + InlineKeyboardButton( + text=_("admin_promo_view_activations", default="📋 Активации"), + callback_data=f"promo_activations:{promo_id}" + ) + ) + + # Delete button + builder.row( + InlineKeyboardButton( + text=_("admin_promo_delete", default="🗑 Удалить"), + callback_data=f"promo_delete:{promo_id}" + ) + ) + + # Back button + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_management", default="🔙 К управлению"), + callback_data="admin_action:promo_management" + ) + ) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error in promo detail: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_toggle:")) +async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Toggle promo code active status""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + new_status = not promo.is_active + update_data = {"is_active": new_status} + + updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if updated: + await session.commit() + status_text = _("admin_promo_activated", default="активирован") if new_status else _("admin_promo_deactivated", default="деактивирован") + await callback.answer( + _("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text) + ) + # Refresh the detail view + callback.data = f"promo_detail:{promo_id}" + await promo_detail_handler(callback, i18n_data, settings, session) + else: + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error toggling promo: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_activations:")) +async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Show promo code activations""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error processing request.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) + + if not promo: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + return + + activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) + + text_lines = [ + _("admin_promo_activations_title", + default="📋 Активации промокода {code}\n", + code=promo.code) + ] + + if not activations: + text_lines.append(_("admin_no_activations", default="❌ Нет активаций")) + else: + for activation in activations[:10]: # Show last 10 + activated_at = activation.activated_at.strftime("%d.%m.%Y %H:%M") if activation.activated_at else "N/A" + text_lines.append( + f"👤 {activation.user_id} | 🕐 {activated_at}" + ) + + if len(activations) > 10: + text_lines.append(f"\n... и еще {len(activations) - 10}") + + text = "\n".join(text_lines) + + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text=_("admin_back_to_promo", default="🔙 К промокоду"), + callback_data=f"promo_detail:{promo_id}" + ) + ) + + try: + await callback.message.edit_text( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + except Exception: + await callback.message.answer( + text, + reply_markup=builder.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error viewing activations: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +@router.callback_query(F.data.startswith("promo_delete:")) +async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Delete promo code""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await callback.answer("Language service error.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + try: + promo_id = int(callback.data.split(":")[1]) + promo = await promo_code_dal.delete_promo_code(session, promo_id) + + if promo: + await session.commit() + await callback.answer( + _("admin_promo_deleted", default="✅ Промокод удален", code=promo.code) + ) + # Go back to management + callback.data = "admin_action:promo_management" + await promo_management_handler(callback, i18n_data, settings, session) + else: + await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True) + + except ValueError: + await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True) + except Exception as e: + logging.error(f"Error deleting promo: {e}") + await callback.answer(_("error_occurred_try_again"), show_alert=True) + + +# Legacy handlers that redirect to new system +async def manage_promo_codes_handler(callback: types.CallbackQuery, + i18n_data: dict, settings: Settings, + session: AsyncSession): + """Redirect to new unified handler""" + await promo_management_handler(callback, i18n_data, settings, session) \ No newline at end of file diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py deleted file mode 100644 index aacf286..0000000 --- a/bot/handlers/admin/promo_codes.py +++ /dev/null @@ -1,1611 +0,0 @@ -import logging -import random -import string -from aiogram import Router, F, types, Bot -from aiogram.filters import StateFilter -from aiogram.fsm.context import FSMContext -from datetime import datetime, timedelta, timezone -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession - -from config.settings import Settings - -from db.dal import promo_code_dal - -from bot.states.admin_states import AdminStates -from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard -from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton -from bot.middlewares.i18n import JsonI18n - -router = Router(name="admin_promo_codes_router") - - -async def create_promo_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error preparing promo creation.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - # Step 1: Ask for promo code - prompt_text = _( - "admin_promo_step1_code", - default="🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" - ) - - try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - except Exception as e: - logging.warning( - f"Could not edit message for promo prompt: {e}. Sending new.") - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - await callback.answer() - await state.set_state(AdminStates.waiting_for_promo_code) - - -# Step 1: Process promo code -@router.message(AdminStates.waiting_for_promo_code, F.text) -async def process_promo_code_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - code_str = message.text.strip().upper() - if not (3 <= len(code_str) <= 30 and code_str.isalnum()): - await message.answer(_( - "admin_promo_invalid_code_format", - default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" - )) - return - - # Check if code already exists - existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) - if existing_promo: - await message.answer(_( - "admin_promo_code_already_exists", - default="❌ Промокод с таким кодом уже существует" - )) - return - - await state.update_data(promo_code=code_str) - - # Step 2: Ask for bonus days - prompt_text = _( - "admin_promo_step2_bonus_days", - default="🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", - code=code_str - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_bonus_days) - - except Exception as e: - logging.error(f"Error processing promo code: {e}") - await message.answer(_( - "admin_promo_processing_error", - default="❌ Ошибка обработки промокода" - )) - -# Step 2: Process bonus days -@router.message(AdminStates.waiting_for_promo_bonus_days, F.text) -async def process_promo_bonus_days_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - bonus_days = int(message.text.strip()) - if bonus_days <= 0 or bonus_days > 365: - await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Бонусные дни должны быть от 1 до 365" - )) - return - - data = await state.get_data() - await state.update_data(bonus_days=bonus_days) - - # Step 3: Ask for max activations - prompt_text = _( - "admin_promo_step3_max_activations", - default="🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", - code=data['promo_code'], - bonus_days=bonus_days - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_max_activations) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Бонусные дни должны быть от 1 до 365" - )) - -# Step 3: Process max activations -@router.message(AdminStates.waiting_for_promo_max_activations, F.text) -async def process_promo_max_activations_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - max_activations = int(message.text.strip()) - if max_activations <= 0 or max_activations > 10000: - await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Максимальные активации должны быть от 1 до 10000" - )) - return - - data = await state.get_data() - await state.update_data(max_activations=max_activations) - - # Step 4: Ask for validity days (with buttons) - keyboard = InlineKeyboardBuilder() - keyboard.row( - InlineKeyboardButton( - text=_("admin_promo_unlimited_validity", default="♾️ Неограниченно"), - callback_data="promo_unlimited_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_promo_set_validity", default="⏰ Установить срок"), - callback_data="promo_set_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_panel_back_button", default="⬅️ Назад"), - callback_data="admin_panel_back" - ) - ) - - prompt_text = _( - "admin_promo_step4_validity", - default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", - code=data['promo_code'], - bonus_days=data['bonus_days'], - max_activations=max_activations - ) - - await message.answer( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_validity_days) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Максимальные активации должны быть от 1 до 10000" - )) - -# Handle validity choice buttons -@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) -async def process_promo_unlimited_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - await state.update_data(validity_days=None) - await create_promo_code_final(callback, state, i18n_data, settings, session) - -@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)) -async def process_promo_set_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - prompt_text = _( - "admin_promo_enter_validity_days", - default="⏰ Введите количество дней действия промокода (1-365):" - ) - - try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - except Exception as e: - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - await callback.answer() - -# Step 4: Process validity days (when user chooses to set) -@router.message(AdminStates.waiting_for_promo_validity_days, F.text) -async def process_promo_validity_days_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - validity_days = int(message.text.strip()) - if validity_days <= 0 or validity_days > 365: - await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - return - - await state.update_data(validity_days=validity_days) - - # Create a fake callback for consistency - fake_callback = types.CallbackQuery( - id="fake", - from_user=message.from_user, - chat_instance="fake", - message=message, - data="fake" - ) - - await create_promo_code_final(fake_callback, state, i18n_data, settings, session) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - -# Description handlers removed - not needed for this model - -async def create_promo_code_final(callback_or_message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - """Final step to create the promo code""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer("Language service error.", show_alert=True) - else: - await callback_or_message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - promo_code = data['promo_code'] - bonus_days = data['bonus_days'] - max_activations = data['max_activations'] - validity_days = data.get('validity_days') - - # Create validity date - valid_until_date: Optional[datetime] = None - valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") - - if validity_days: - valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) - valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') - - # Show creating message - creating_text = _( - "admin_promo_creating", - default="⏳ Создаю промокод..." - ) - - if hasattr(callback_or_message, 'message'): - try: - await callback_or_message.message.edit_text(creating_text) - except: - await callback_or_message.message.answer(creating_text) - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer() - else: - await callback_or_message.answer(creating_text) - - # Create promo code - try: - promo_data = { - "code": promo_code, - "bonus_days": bonus_days, - "max_activations": max_activations, - "current_activations": 0, - "is_active": True, - "valid_until": valid_until_date, - "created_by_admin_id": callback_or_message.from_user.id if hasattr(callback_or_message, 'from_user') and callback_or_message.from_user else 0 - } - - await promo_code_dal.create_promo_code(session, promo_data) - await session.commit() - - # Send success message - success_text = _( - "admin_promo_created_success", - default="✅ Промокод создан!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {bonus_days}\n🔢 Максимальные активации: {max_activations}\n⏰ Действует до: {valid_until_str}", - code=promo_code, - bonus_days=bonus_days, - max_activations=max_activations, - valid_until_str=valid_until_str_display - ) - - if hasattr(callback_or_message, 'message'): - target_message = callback_or_message.message - else: - target_message = callback_or_message - - await target_message.answer( - success_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - - except Exception as e: - logging.error(f"Error creating promo code: {e}") - await session.rollback() - error_text = _( - "admin_promo_creation_failed", - default="❌ Ошибка создания промокода: {error}", - error=str(e) - ) - - if hasattr(callback_or_message, 'message'): - target_message = callback_or_message.message - else: - target_message = callback_or_message - - await target_message.answer( - error_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - - await state.clear() - -# Legacy handler removed - using step-by-step creation now - - -async def view_promo_codes_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying promo codes.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_models = await promo_code_dal.get_all_active_promo_codes(session, - limit=20, - offset=0) - - if not promo_models: - await callback.message.edit_text( - _("admin_no_active_promos"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - return - - response_text_parts = [f"{_('admin_active_promos_list_header')}\n"] - for promo in promo_models: - valid_until_display_text = _("admin_promo_valid_indefinitely") - if promo.valid_until: - - valid_until_display_text = promo.valid_until.strftime('%Y-%m-%d') - - response_text_parts.append( - _("admin_promo_list_item", - code=promo.code, - bonus=promo.bonus_days, - current=promo.current_activations, - max=promo.max_activations, - valid_until=valid_until_display_text)) - - final_text = "\n".join(response_text_parts) - try: - await callback.message.edit_text( - final_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - except Exception as e: - logging.warning( - f"Failed to edit message for promo list: {e}. Sending new.") - if callback.message: - await callback.message.answer( - final_text, - reply_markup=get_back_to_admin_panel_keyboard( - current_lang, i18n), - parse_mode="HTML") - await callback.answer() - - -# New unified promo management system -@router.callback_query(F.data == "admin_action:promo_management") -async def promo_management_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """Show list of all promo codes for management""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying promo codes.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - # Get ALL promo codes (including inactive) - promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0) - if not promo_models: - await callback.message.edit_text( - _("admin_promo_management_empty", default="📭 Промокоды отсутствуют"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - return - - kb = InlineKeyboardBuilder() - for promo in promo_models: - # Show promo code with status indicator - status_emoji = "✅" if promo.is_active else "🚫" - if promo.valid_until and promo.valid_until < datetime.now(timezone.utc): - status_emoji = "⏰" # Expired - elif promo.current_activations >= promo.max_activations: - status_emoji = "🔄" # Used up - - button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})" - kb.row( - InlineKeyboardButton( - text=button_text, - callback_data=f"promo_detail:{promo.promo_code_id}") - ) - - kb.row( - InlineKeyboardButton(text=_("back_to_admin_panel_button", default="⬅️ Назад"), - callback_data="admin_action:main")) - - await callback.message.edit_text( - _("admin_promo_management_title", default="🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:"), - reply_markup=kb.as_markup(), - parse_mode="HTML") - await callback.answer() - - -@router.callback_query(F.data.startswith("promo_detail:")) -async def promo_detail_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - """Show detailed promo code information with management options""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error displaying promo details.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - if not promo: - await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) - return - - # Determine status - status = _("admin_promo_status_active", default="✅ Активен") - if not promo.is_active: - status = _("admin_promo_status_inactive", default="🚫 Неактивен") - elif promo.valid_until and promo.valid_until < datetime.now(timezone.utc): - status = _("admin_promo_status_expired", default="⏰ Истек") - elif promo.current_activations >= promo.max_activations: - status = _("admin_promo_status_used_up", default="🔄 Исчерпан") - - # Format validity - validity = _("admin_promo_valid_indefinitely", default="Неограниченно") - if promo.valid_until: - validity = promo.valid_until.strftime('%Y-%m-%d %H:%M') - - # Format created date - created_date = promo.created_at.strftime('%Y-%m-%d %H:%M') if promo.created_at else "N/A" - creator = f"Admin {promo.created_by_admin_id}" if promo.created_by_admin_id else "N/A" - - # Build card text - card_text = _( - "admin_promo_card_title", - default="🎟 Промокод: {code}", - code=promo.code - ) + "\n\n" - - card_text += _( - "admin_promo_card_bonus_days", - default="🎁 Бонусные дни: {days}", - days=promo.bonus_days - ) + "\n" - - card_text += _( - "admin_promo_card_activations", - default="🔢 Активации: {current}/{max}", - current=promo.current_activations, - max=promo.max_activations - ) + "\n" - - card_text += _( - "admin_promo_card_validity", - default="⏰ Действует до: {validity}", - validity=validity - ) + "\n" - - card_text += _( - "admin_promo_card_status", - default="📊 Статус: {status}", - status=status - ) + "\n" - - card_text += _( - "admin_promo_card_created", - default="📅 Создан: {created}", - created=created_date - ) + "\n" - - card_text += _( - "admin_promo_card_created_by", - default="👤 Создал: {creator}", - creator=creator - ) - - # Build keyboard - kb = InlineKeyboardBuilder() - - # Row 1: Edit and Toggle status - kb.row( - InlineKeyboardButton( - text=_("admin_promo_edit_button", default="✏️ Редактировать"), - callback_data=f"promo_edit:{promo_id}"), - InlineKeyboardButton( - text=_("admin_promo_toggle_status_button", default="🔄 Вкл/Выкл"), - callback_data=f"promo_toggle:{promo_id}") - ) - - # Row 2: View activations and Delete - kb.row( - InlineKeyboardButton( - text=_("admin_promo_view_activations_button", default="📋 Активации"), - callback_data=f"promo_activations:{promo_id}"), - InlineKeyboardButton( - text=_("admin_promo_delete_button", default="🗑 Удалить"), - callback_data=f"promo_delete:{promo_id}") - ) - - # Row 3: Back to list - kb.row( - InlineKeyboardButton( - text=_("admin_promo_back_to_list_button", default="⬅️ К списку"), - callback_data="admin_action:promo_management") - ) - - await callback.message.edit_text( - card_text, - reply_markup=kb.as_markup(), - parse_mode="HTML") - await callback.answer() - - -# Legacy manage_promo_codes_handler - keeping for compatibility -@router.callback_query(F.data == "admin_action:manage_promos") -async def manage_promo_codes_handler(callback: types.CallbackQuery, - i18n_data: dict, settings: Settings, - session: AsyncSession): - # Redirect to new unified handler - await promo_management_handler(callback, i18n_data, settings, session) - - -@router.callback_query(F.data.startswith("promo_edit:")) -async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not promo or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - await state.update_data( - edit_promo_id=promo_id, - original_code=promo.code, - original_bonus_days=promo.bonus_days, - original_max_activations=promo.max_activations, - original_valid_until=promo.valid_until - ) - - # Step 1: Edit code - prompt_text = _( - "admin_promo_edit_step1_code", - default="✏️ Редактирование промокода\n\nШаг 1 из 4: Код промокода\n\nТекущий код: {current_code}\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:", - current_code=promo.code - ) - - await state.set_state(AdminStates.waiting_for_promo_edit_code) - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - await callback.answer() - - -# Step 1: Process edited code -@router.message(AdminStates.waiting_for_promo_edit_code, F.text) -async def process_promo_edit_code_handler(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - original_code = data.get("original_code") - - try: - code_str = message.text.strip().upper() - if not (3 <= len(code_str) <= 30 and code_str.isalnum()): - await message.answer(_( - "admin_promo_invalid_code_format", - default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" - )) - return - - # Check if code already exists (except current promo) - if code_str != original_code: - existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) - if existing_promo: - await message.answer(_( - "admin_promo_code_already_exists", - default="❌ Промокод с таким кодом уже существует" - )) - return - - await state.update_data(new_code=code_str) - - # Step 2: Ask for bonus days - prompt_text = _( - "admin_promo_edit_step2_bonus_days", - default="✏️ Редактирование промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\nТекущие дни: {current_days}\n\nВведите количество бонусных дней (1-365):", - code=code_str, - current_days=data.get("original_bonus_days") - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_edit_bonus_days) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_code_format", - default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" - )) - - -# Step 2: Process edited bonus days -@router.message(AdminStates.waiting_for_promo_edit_bonus_days, F.text) -async def process_promo_edit_bonus_days_handler(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - - try: - bonus_days = int(message.text.strip()) - if not (1 <= bonus_days <= 365): - await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Количество дней должно быть от 1 до 365" - )) - return - - await state.update_data(new_bonus_days=bonus_days) - - # Step 3: Ask for max activations - prompt_text = _( - "admin_promo_edit_step3_max_activations", - default="✏️ Редактирование промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nДни: {days}\nТекущие активации: {current_max}\n\nВведите максимальное количество активаций (1-10000):", - code=data.get("new_code"), - days=bonus_days, - current_max=data.get("original_max_activations") - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_edit_max_activations) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Количество дней должно быть от 1 до 365" - )) - - -# Step 3: Process edited max activations -@router.message(AdminStates.waiting_for_promo_edit_max_activations, F.text) -async def process_promo_edit_max_activations_handler(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - - try: - max_activations = int(message.text.strip()) - if not (1 <= max_activations <= 10000): - await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Количество активаций должно быть от 1 до 10000" - )) - return - - await state.update_data(new_max_activations=max_activations) - - # Step 4: Ask for validity days (with buttons) - current_valid_until = data.get("original_valid_until") - current_validity_str = _("admin_promo_valid_indefinitely", default="Неограниченно") - if current_valid_until: - current_validity_str = current_valid_until.strftime('%Y-%m-%d') - - keyboard = InlineKeyboardBuilder() - keyboard.row( - InlineKeyboardButton( - text=_("admin_promo_edit_unlimited_validity", default="♾️ Неограниченно"), - callback_data="promo_edit_unlimited_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_promo_edit_set_validity", default="⏰ Установить срок"), - callback_data="promo_edit_set_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_panel_back_button", default="⬅️ Назад"), - callback_data="admin_panel_back" - ) - ) - - prompt_text = _( - "admin_promo_edit_step4_validity", - default="✏️ Редактирование промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nДни: {days}\nАктивации: {max_act}\nТекущий срок: {current_validity}\n\nВыберите срок действия промокода:", - code=data.get("new_code"), - days=data.get("new_bonus_days"), - max_act=max_activations, - current_validity=current_validity_str - ) - - await message.answer( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_promo_edit_validity_days) - - except ValueError: - await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Количество активаций должно быть от 1 до 10000" - )) - - -# Handle edit validity choice buttons -@router.callback_query(F.data == "promo_edit_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_edit_validity_days)) -async def process_promo_edit_unlimited_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - promo_id = data.get("edit_promo_id") - - # Update promo code with unlimited validity - update_data = { - "code": data.get("new_code"), - "bonus_days": data.get("new_bonus_days"), - "max_activations": data.get("new_max_activations"), - "valid_until": None, # Unlimited - } - - # Show updating message - try: - await callback.message.edit_text( - _("admin_promo_creating", default="⏳ Обновляю промокод..."), - ) - except: - await callback.message.answer( - _("admin_promo_creating", default="⏳ Обновляю промокод..."), - ) - await callback.answer() - - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: - await session.commit() - - success_text = _( - "admin_promo_updated_success", - default="✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", - code=data.get("new_code"), - days=data.get("new_bonus_days"), - max_act=data.get("new_max_activations"), - validity=_("admin_promo_valid_indefinitely", default="Неограниченно") - ) - - await callback.message.answer( - success_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - else: - await session.rollback() - await callback.message.answer(_( - "admin_promo_update_failed", - default="❌ Ошибка обновления промокода" - )) - - await state.clear() - -@router.callback_query(F.data == "promo_edit_set_validity", StateFilter(AdminStates.waiting_for_promo_edit_validity_days)) -async def process_promo_edit_set_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - prompt_text = _( - "admin_promo_edit_enter_validity_days", - default="⏰ Введите количество дней действия промокода (1-365):" - ) - - try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - except Exception as e: - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - await callback.answer() - -# Step 4: Process edited validity and finalize -@router.message(AdminStates.waiting_for_promo_edit_validity_days, F.text) -async def process_promo_edit_validity_handler(message: types.Message, state: FSMContext, - i18n_data: dict, settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - promo_id = data.get("edit_promo_id") - - try: - validity_days = int(message.text.strip()) - if not (1 <= validity_days <= 365): - await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - return - - # Calculate validity date - valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) - valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') - - # Update promo code - update_data = { - "code": data.get("new_code"), - "bonus_days": data.get("new_bonus_days"), - "max_activations": data.get("new_max_activations"), - "valid_until": valid_until_date, - } - - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: - await session.commit() - - success_text = _( - "admin_promo_updated_success", - default="✅ Промокод обновлен!\n\n🎟 Код: {code}\n🎁 Бонусные дни: {days}\n🔢 Максимальные активации: {max_act}\n⏰ Действует до: {validity}", - code=data.get("new_code"), - days=data.get("new_bonus_days"), - max_act=data.get("new_max_activations"), - validity=valid_until_str_display - ) - - await message.answer( - success_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - else: - await session.rollback() - await message.answer(_( - "admin_promo_update_failed", - default="❌ Ошибка обновления промокода" - )) - - await state.clear() - - except ValueError: - await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - - -@router.callback_query(F.data.startswith("promo_toggle:")) -async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - """Toggle promo code active status""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - if not promo: - await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) - return - - # Toggle status - new_status = not promo.is_active - update_data = {"is_active": new_status} - - updated = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if updated: - await session.commit() - - status_text = _("admin_promo_status_activated", default="активирован") if new_status else _("admin_promo_status_deactivated", default="деактивирован") - success_text = _( - "admin_promo_toggle_success", - default="✅ Промокод {code} {status}", - code=promo.code, - status=status_text - ) - - await callback.answer(success_text, show_alert=True) - - # Refresh the detail view - await promo_detail_handler(callback, i18n_data, settings, session) - else: - await session.rollback() - await callback.answer(_( - "admin_promo_toggle_failed", - default="❌ Ошибка изменения статуса промокода" - ), show_alert=True) - - -@router.callback_query(F.data.startswith("promo_activations:")) -async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - """Show promo code activations""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.get_promo_code_by_id(session, promo_id) - if not promo: - await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True) - return - - # Get activations for this promo code - activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id) - - if not activations: - text = _( - "admin_promo_no_activations", - default="📋 Активации промокода: {code}\n\n❌ Активаций не найдено", - code=promo.code - ) - else: - text_parts = [_( - "admin_promo_activations_header", - default="📋 Активации промокода: {code}\n\n", - code=promo.code - )] - - for activation in activations: - activation_date = activation.activated_at.strftime('%Y-%m-%d %H:%M') if activation.activated_at else "N/A" - text_parts.append(_( - "admin_promo_activation_item", - default="👤 User ID: {user_id}\n📅 Дата: {date}\n", - user_id=activation.user_id, - date=activation_date - )) - - text = "".join(text_parts) - - # Build keyboard with back button - kb = InlineKeyboardBuilder() - kb.row( - InlineKeyboardButton( - text=_("admin_promo_back_to_detail_button", default="⬅️ К промокоду"), - callback_data=f"promo_detail:{promo_id}") - ) - - await callback.message.edit_text( - text, - reply_markup=kb.as_markup(), - parse_mode="HTML") - await callback.answer() - - -@router.callback_query(F.data.startswith("promo_delete:")) -async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, - settings: Settings, session: AsyncSession): - promo_id = int(callback.data.split(":")[1]) - promo = await promo_code_dal.delete_promo_code(session, promo_id) - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - if promo: - await session.commit() - await callback.message.edit_text( - _("admin_promo_deleted_success", code=promo.code), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - else: - await session.rollback() - await callback.message.edit_text( - _("admin_promo_not_found"), - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)) - await callback.answer() - - -@router.callback_query( - F.data == "admin_action:main", - StateFilter( - AdminStates.waiting_for_promo_details, - AdminStates.waiting_for_promo_code, - AdminStates.waiting_for_promo_bonus_days, - AdminStates.waiting_for_promo_max_activations, - AdminStates.waiting_for_promo_validity_days, - AdminStates.waiting_for_promo_edit_details, - AdminStates.waiting_for_bulk_promo_quantity, - AdminStates.waiting_for_bulk_promo_bonus_days, - AdminStates.waiting_for_bulk_promo_max_activations, - AdminStates.waiting_for_bulk_promo_validity_days, - ), -) -async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, - state: FSMContext, - settings: Settings, - i18n_data: dict, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error cancelling.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - await callback.message.edit_text(_("admin_action_cancelled_default"), - reply_markup=get_admin_panel_keyboard( - i18n, current_lang, settings)) - except Exception: - await callback.message.answer(_("admin_action_cancelled_default"), - reply_markup=get_admin_panel_keyboard( - i18n, current_lang, settings)) - - await callback.answer(_("admin_action_cancelled_default_alert")) - await state.clear() - - -async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, - state: FSMContext, i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n or not callback.message: - await callback.answer("Error preparing bulk promo creation.", - show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - # Step 1: Ask for quantity - prompt_text = _( - "admin_bulk_promo_step1_quantity", - default="📦 Массовое создание промокодов\n\nШаг 1 из 4: Количество промокодов\n\nВведите количество промокодов для создания (1-1000):" - ) - - try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - except Exception as e: - logging.warning( - f"Could not edit message for bulk promo prompt: {e}. Sending new.") - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML") - await callback.answer() - await state.set_state(AdminStates.waiting_for_bulk_promo_quantity) - - -def generate_unique_promo_code(length: int = 8) -> str: - """Generate a unique promotional code""" - characters = string.ascii_uppercase + string.digits - # Exclude confusing characters - characters = characters.replace('0', '').replace('O', '').replace('1', '').replace('I', '').replace('L', '') - return ''.join(random.choice(characters) for _ in range(length)) - - -# Step 1: Process quantity -@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text) -async def process_bulk_promo_quantity_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - quantity = int(message.text.strip()) - if quantity <= 0 or quantity > 1000: - await message.answer(_( - "admin_bulk_promo_invalid_quantity", - default="❌ Количество должно быть от 1 до 1000" - )) - return - - await state.update_data(quantity=quantity) - - # Step 2: Ask for bonus days - prompt_text = _( - "admin_bulk_promo_step2_bonus_days", - default="📦 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней (1-365):", - quantity=quantity - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days) - - except ValueError: - await message.answer(_( - "admin_bulk_promo_invalid_quantity", - default="❌ Количество должно быть от 1 до 1000" - )) - -# Step 2: Process bonus days -@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text) -async def process_bulk_promo_bonus_days_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - bonus_days = int(message.text.strip()) - if bonus_days <= 0 or bonus_days > 365: - await message.answer(_( - "admin_bulk_promo_invalid_bonus_days", - default="❌ Бонусные дни должны быть от 1 до 365" - )) - return - - data = await state.get_data() - await state.update_data(bonus_days=bonus_days) - - # Step 3: Ask for max activations - prompt_text = _( - "admin_bulk_promo_step3_max_activations", - default="📦 Массовое создание промокодов\n\nШаг 3 из 4: Максимальные активации\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", - quantity=data['quantity'], - bonus_days=bonus_days - ) - - await message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations) - - except ValueError: - await message.answer(_( - "admin_bulk_promo_invalid_bonus_days", - default="❌ Бонусные дни должны быть от 1 до 365" - )) - -# Step 3: Process max activations -@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text) -async def process_bulk_promo_max_activations_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - max_activations = int(message.text.strip()) - if max_activations <= 0 or max_activations > 10000: - await message.answer(_( - "admin_bulk_promo_invalid_max_activations", - default="❌ Максимальные активации должны быть от 1 до 10000" - )) - return - - data = await state.get_data() - await state.update_data(max_activations=max_activations) - - # Step 4: Ask for validity days (optional) - keyboard = InlineKeyboardBuilder() - keyboard.row( - InlineKeyboardButton( - text=_("admin_bulk_promo_unlimited_validity", default="♾️ Неограниченно"), - callback_data="bulk_promo_unlimited_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_bulk_promo_set_validity", default="⏰ Установить срок"), - callback_data="bulk_promo_set_validity" - ) - ) - keyboard.row( - InlineKeyboardButton( - text=_("admin_panel_back_button", default="⬅️ Назад"), - callback_data="admin_panel_back" - ) - ) - - prompt_text = _( - "admin_bulk_promo_step4_validity", - default="📦 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокодов:", - quantity=data['quantity'], - bonus_days=data['bonus_days'], - max_activations=max_activations - ) - - await message.answer( - prompt_text, - reply_markup=keyboard.as_markup(), - parse_mode="HTML" - ) - await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days) - - except ValueError: - await message.answer(_( - "admin_bulk_promo_invalid_max_activations", - default="❌ Максимальные активации должны быть от 1 до 10000" - )) - -# Handle validity choice buttons -@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) -async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - await state.update_data(validity_days=None) - await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session) - -@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days)) -async def process_bulk_promo_set_validity(callback: types.CallbackQuery, - state: FSMContext, - i18n_data: dict, - settings: Settings): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await callback.answer("Language service error.", show_alert=True) - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - prompt_text = _( - "admin_bulk_promo_enter_validity_days", - default="⏰ Введите количество дней действия промокодов (1-365):" - ) - - try: - await callback.message.edit_text( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - except Exception as e: - await callback.message.answer( - prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - await callback.answer() - -# Step 4: Process validity days (when user chooses to set) -@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text) -async def process_bulk_promo_validity_days_handler(message: types.Message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - await message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - try: - validity_days = int(message.text.strip()) - if validity_days <= 0 or validity_days > 365: - await message.answer(_( - "admin_bulk_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - return - - await state.update_data(validity_days=validity_days) - - # Create a fake callback for consistency - fake_callback = types.CallbackQuery( - id="fake", - from_user=message.from_user, - chat_instance="fake", - message=message, - data="fake" - ) - - await create_bulk_promo_codes_final(fake_callback, state, i18n_data, settings, session) - - except ValueError: - await message.answer(_( - "admin_bulk_promo_invalid_validity_days", - default="❌ Дни действия должны быть от 1 до 365" - )) - -async def create_bulk_promo_codes_final(callback_or_message, - state: FSMContext, - i18n_data: dict, - settings: Settings, - session: AsyncSession): - """Final step to create all promo codes""" - current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) - i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not i18n: - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer("Language service error.", show_alert=True) - else: - await callback_or_message.reply("Language service error.") - return - _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) - - data = await state.get_data() - quantity = data['quantity'] - bonus_days = data['bonus_days'] - max_activations = data['max_activations'] - validity_days = data.get('validity_days') - - # Create validity date - valid_until_date: Optional[datetime] = None - valid_until_str_display = _("admin_promo_valid_indefinitely", default="Неограниченно") - - if validity_days: - valid_until_date = datetime.now(timezone.utc) + timedelta(days=validity_days) - valid_until_str_display = valid_until_date.strftime('%Y-%m-%d') - - # Show creating message - creating_text = _( - "admin_bulk_promo_creating", - default="⏳ Создаю {quantity} промокодов...", - quantity=quantity - ) - - if hasattr(callback_or_message, 'message'): - try: - await callback_or_message.message.edit_text(creating_text) - except: - await callback_or_message.message.answer(creating_text) - if hasattr(callback_or_message, 'answer'): - await callback_or_message.answer() - else: - await callback_or_message.answer(creating_text) - - # Generate and create promo codes - created_codes = [] - try: - for i in range(quantity): - # Generate unique code - promo_code = generate_unique_promo_code() - - # Ensure uniqueness - existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) - retries = 0 - while existing_promo and retries < 10: - promo_code = generate_unique_promo_code() - existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code) - retries += 1 - - if retries >= 10: - raise Exception("Не удалось создать уникальный промокод") - - # Create promo code - promo_data = { - "code": promo_code, - "bonus_days": bonus_days, - "max_activations": max_activations, - "current_activations": 0, - "is_active": True, - "valid_until": valid_until_date, - "created_by_admin_id": callback_or_message.from_user.id if hasattr(callback_or_message, 'from_user') and callback_or_message.from_user else 0 - } - - await promo_code_dal.create_promo_code(session, promo_data) - created_codes.append(promo_code) - - await session.commit() - - # Send success message - success_text = _( - "admin_bulk_promo_created_success", - default="✅ Создано {count} промокодов на {days} дней!\n\nДействуют до: {validity}\nМакс. активации: {max_act}", - count=quantity, - days=bonus_days, - validity=valid_until_str_display, - max_act=max_activations - ) - - if hasattr(callback_or_message, 'message'): - target_message = callback_or_message.message - else: - target_message = callback_or_message - - await target_message.answer( - success_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), - parse_mode="HTML" - ) - - # Always send codes as file - codes_file_content = "\n".join(created_codes) - codes_file = types.BufferedInputFile( - codes_file_content.encode('utf-8'), - filename=f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" - ) - - await target_message.answer_document( - codes_file, - caption=_( - "admin_bulk_promo_codes_file", - default="📄 Все созданные промокоды в файле ({count} шт.)", - count=quantity - ) - ) - - except Exception as e: - logging.error(f"Error creating bulk promo codes: {e}") - await session.rollback() - error_text = _( - "admin_bulk_promo_creation_failed", - default="❌ Ошибка создания промокодов: {error}", - error=str(e) - ) - - if hasattr(callback_or_message, 'message'): - target_message = callback_or_message.message - else: - target_message = callback_or_message - - await target_message.answer( - error_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) - ) - - await state.clear() - -# Old bulk promo handler removed - now using step-by-step process - - - diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index dce3514..6f87e82 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -38,14 +38,6 @@ async def create_payment_record(session: AsyncSession, return new_payment -async def get_payment_by_yookassa_id( - session: AsyncSession, yookassa_payment_id: str) -> Optional[Payment]: - stmt = select(Payment).where( - Payment.yookassa_payment_id == yookassa_payment_id) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - async def get_payment_by_provider_payment_id( session: AsyncSession, provider_payment_id: str) -> Optional[Payment]: """Fetch a payment by provider-specific identifier.""" @@ -64,15 +56,6 @@ async def get_payment_by_db_id(session: AsyncSession, return result.scalar_one_or_none() -async def get_payment_by_db_id_with_promo( - session: AsyncSession, payment_db_id: int) -> Optional[Payment]: - - stmt = select(Payment).where(Payment.payment_id == payment_db_id).options( - selectinload(Payment.promo_code_used)) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - async def update_payment_status_by_db_id( session: AsyncSession, payment_db_id: int, @@ -96,38 +79,6 @@ async def update_payment_status_by_db_id( return payment -async def user_has_successful_payment_for_provider( - session: AsyncSession, user_id: int, provider: str) -> bool: - """Check if a user has at least one successful payment for the provider.""" - - stmt = (select(Payment.payment_id) - .where(Payment.user_id == user_id, - Payment.provider == provider, - Payment.status == 'succeeded') - .limit(1)) - result = await session.execute(stmt) - return result.scalar_one_or_none() is not None - - -async def update_payment_status_by_yk_id(session: AsyncSession, - yookassa_payment_id: str, - new_status: str) -> Optional[Payment]: - payment = await get_payment_by_yookassa_id(session, yookassa_payment_id) - if payment: - payment.status = new_status - payment.updated_at = func.now() - await session.flush() - await session.refresh(payment) - logging.info( - f"Payment record with YK ID {yookassa_payment_id} status updated to {new_status}." - ) - else: - logging.warning( - f"Payment record with YK ID {yookassa_payment_id} not found for status update." - ) - return payment - - async def get_recent_payment_logs_with_user(session: AsyncSession, limit: int = 20, offset: int = 0) -> List[Payment]: diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 66d4cf4..f67a2eb 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -31,29 +31,6 @@ async def get_subscription_by_panel_subscription_uuid( return result.scalar_one_or_none() -async def create_subscription(session: AsyncSession, - sub_data: Dict[str, Any]) -> Subscription: - from .user_dal import get_user_by_id - - if "user_id" not in sub_data or sub_data["user_id"] is None: - raise ValueError( - "user_id is required to create a subscription directly.") - user = await get_user_by_id(session, sub_data["user_id"]) - if not user: - raise ValueError( - f"User with id {sub_data['user_id']} not found for creating subscription." - ) - - new_sub = Subscription(**sub_data) - session.add(new_sub) - await session.flush() - await session.refresh(new_sub) - logging.info( - f"Subscription {new_sub.subscription_id} created for user {new_sub.user_id}" - ) - return new_sub - - async def update_subscription( session: AsyncSession, subscription_id: int, update_data: Dict[str, Any]) -> Optional[Subscription]: @@ -205,17 +182,6 @@ async def update_subscription_notification_time( {"last_notification_sent": notification_time}) -async def get_user_active_subscription_end_date_str( - session: AsyncSession, user_id: int) -> Optional[str]: - stmt = (select(Subscription.end_date).where( - Subscription.user_id == user_id, Subscription.is_active == True, - Subscription.end_date > datetime.now(timezone.utc)).order_by( - Subscription.end_date.desc()).limit(1)) - result = await session.execute(stmt) - end_date_obj = result.scalar_one_or_none() - return end_date_obj.strftime('%Y-%m-%d') if end_date_obj else None - - async def find_subscription_for_notification_update( session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime) -> Optional[Subscription]: @@ -234,34 +200,4 @@ async def find_subscription_for_notification_update( return result.scalar_one_or_none() -async def set_skip_notifications_for_provider( - session: AsyncSession, user_id: int, provider: str, - skip: bool) -> int: - stmt = (update(Subscription).where( - Subscription.user_id == user_id, - Subscription.is_active == True, - Subscription.provider == provider).values(skip_notifications=skip)) - result = await session.execute(stmt) - return result.rowcount - -async def get_active_subscriptions_for_autorenew( - session: AsyncSession, provider: str, - days_threshold: int = 1, - require_skip_flag: bool = True) -> List[Subscription]: - """Fetch active subscriptions nearing expiration for auto-renew logic.""" - - now_utc = datetime.now(timezone.utc) - threshold_date = now_utc + timedelta(days=days_threshold) - - conditions = [ - Subscription.provider == provider, - Subscription.is_active == True, - Subscription.end_date <= threshold_date, - ] - if require_skip_flag: - conditions.append(Subscription.skip_notifications == True) - - stmt = select(Subscription).where(*conditions) - result = await session.execute(stmt) - return result.scalars().all() diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py index b661fff..af8f58c 100644 --- a/db/dal/user_dal.py +++ b/db/dal/user_dal.py @@ -81,18 +81,6 @@ async def update_user_language( return result.rowcount > 0 -async def set_user_ban_status( - session: AsyncSession, user_id: int, is_banned: bool -) -> bool: - user = await get_user_by_id(session, user_id) - if user: - user.is_banned = is_banned - await session.flush() - await session.refresh(user) - return True - return False - - async def get_banned_users(session: AsyncSession) -> List[User]: """Get all banned users""" stmt = ( @@ -104,58 +92,12 @@ async def get_banned_users(session: AsyncSession) -> List[User]: return result.scalars().all() -async def get_banned_users_paginated( - session: AsyncSession, limit: int, offset: int -) -> Tuple[List[User], int]: - stmt_users = ( - select(User) - .where(User.is_banned == True) - .order_by(User.registration_date.desc()) - .limit(limit) - .offset(offset) - ) - result_users = await session.execute(stmt_users) - users_list = result_users.scalars().all() - - stmt_count = select(func.count()).select_from(User).where(User.is_banned == True) - result_count = await session.execute(stmt_count) - total_banned = result_count.scalar_one() - - return users_list, total_banned - - async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]: stmt = select(User.user_id).where(User.is_banned == False) result = await session.execute(stmt) return result.scalars().all() -async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]: - total_users_stmt = select(func.count(User.user_id)).select_from(User) - banned_users_stmt = ( - select(func.count(User.user_id)).select_from(User).where(User.is_banned == True) - ) - - active_subs_stmt = ( - select(func.count(func.distinct(Subscription.user_id))) - .join(User, Subscription.user_id == User.user_id) - .where(Subscription.is_active == True) - .where(Subscription.end_date > datetime.now()) - ) - - total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0 - banned_users = (await session.execute(banned_users_stmt)).scalar_one_or_none() or 0 - active_subs_users = ( - await session.execute(active_subs_stmt) - ).scalar_one_or_none() or 0 - - return { - "total_users": total_users, - "banned_users": banned_users, - "users_with_active_subscriptions": active_subs_users, - } - - async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]: stmt = select(User).where(User.panel_user_uuid.is_not(None)) result = await session.execute(stmt) From a46e5a43d6eff78aa93ee543df51d72eb3c93bdf Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 15:31:03 +0300 Subject: [PATCH 23/26] Add user ban and unban functionality with prompts in admin panel - Implemented handlers for banning and unbanning users, allowing admins to input user IDs or usernames. - Added prompts for user input during ban and unban processes, enhancing user experience and clarity. - Updated localization for ban and unban messages in both English and Russian, ensuring consistency and improved communication. - Refactored user display logic in the banned users list to use updated attributes, enhancing data accuracy. --- bot/handlers/admin/user_management.py | 214 +++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 4 deletions(-) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 747c8e7..b83b711 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -653,6 +653,70 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte await state.clear() +async def ban_user_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Prompt admin to enter user ID or username to ban""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing ban prompt.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_ban_user_prompt", + default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + logging.warning(f"Could not edit message for ban prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_id_to_ban) + + +async def unban_user_prompt_handler(callback: types.CallbackQuery, + state: FSMContext, i18n_data: dict, + settings: Settings, session: AsyncSession): + """Prompt admin to enter user ID or username to unban""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n or not callback.message: + await callback.answer("Error preparing unban prompt.", show_alert=True) + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + prompt_text = _( + "admin_unban_user_prompt", + default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:" + ) + + try: + await callback.message.edit_text( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + except Exception as e: + logging.warning(f"Could not edit message for unban prompt: {e}. Sending new.") + await callback.message.answer( + prompt_text, + reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n) + ) + + await callback.answer() + await state.set_state(AdminStates.waiting_for_user_id_to_unban) + + async def view_banned_users_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings, session: AsyncSession): @@ -676,9 +740,9 @@ async def view_banned_users_handler(callback: types.CallbackQuery, else: user_list = [] for user in banned_users: - display_name = user.telegram_first_name or "Unknown" - if user.telegram_username: - display_name = f"@{user.telegram_username}" + display_name = user.first_name or "Unknown" + if user.username: + display_name = f"@{user.username}" user_list.append(f"• {display_name} (ID: {user.user_id})") message_text = _( @@ -695,4 +759,146 @@ async def view_banned_users_handler(callback: types.CallbackQuery, except Exception as e: logging.error(f"Error displaying banned users: {e}") - await callback.answer("Error loading banned users", show_alert=True) \ No newline at end of file + await callback.answer("Error loading banned users", show_alert=True) + + +@router.message(AdminStates.waiting_for_user_id_to_ban, F.text) +async def process_ban_user_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + """Process user ban input""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + input_text = message.text.strip() if message.text else "" + user_model: Optional[User] = None + + # Try to find user by ID or username + if input_text.isdigit(): + try: + user_model = await user_dal.get_user_by_id(session, int(input_text)) + except ValueError: + pass + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_model = await user_dal.get_user_by_username(session, input_text[1:]) + elif USERNAME_REGEX.match(input_text): + user_model = await user_dal.get_user_by_username(session, input_text) + + if not user_model: + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) + return + + try: + # Check if user is already banned + if user_model.is_banned: + await message.answer(_( + "admin_user_already_banned", + default="⚠️ Пользователь уже заблокирован" + )) + await state.clear() + return + + # Ban the user + await user_dal.update_user(session, user_model.user_id, {"is_banned": True}) + + # Update on panel if user has panel UUID + if user_model.panel_user_uuid: + await panel_service.update_user_status_on_panel(user_model.panel_user_uuid, False) + + await session.commit() + + await message.answer(_( + "admin_user_ban_success", + default="✅ Пользователь {input} заблокирован", + input=hcode(input_text) + )) + + except Exception as e: + logging.error(f"Error banning user {user_model.user_id}: {e}") + await session.rollback() + await message.answer(_( + "admin_user_ban_error", + default="❌ Ошибка блокировки пользователя" + )) + + await state.clear() + + +@router.message(AdminStates.waiting_for_user_id_to_unban, F.text) +async def process_unban_user_handler(message: types.Message, state: FSMContext, + settings: Settings, i18n_data: dict, + panel_service: PanelApiService, + session: AsyncSession): + """Process user unban input""" + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + if not i18n: + await message.reply("Language service error.") + return + _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) + + input_text = message.text.strip() if message.text else "" + user_model: Optional[User] = None + + # Try to find user by ID or username + if input_text.isdigit(): + try: + user_model = await user_dal.get_user_by_id(session, int(input_text)) + except ValueError: + pass + elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]): + user_model = await user_dal.get_user_by_username(session, input_text[1:]) + elif USERNAME_REGEX.match(input_text): + user_model = await user_dal.get_user_by_username(session, input_text) + + if not user_model: + await message.answer(_( + "admin_user_not_found", + default="❌ Пользователь не найден: {input}", + input=hcode(input_text) + )) + return + + try: + # Check if user is not banned + if not user_model.is_banned: + await message.answer(_( + "admin_user_not_banned", + default="⚠️ Пользователь не заблокирован" + )) + await state.clear() + return + + # Unban the user + await user_dal.update_user(session, user_model.user_id, {"is_banned": False}) + + # Update on panel if user has panel UUID + if user_model.panel_user_uuid: + await panel_service.update_user_status_on_panel(user_model.panel_user_uuid, True) + + await session.commit() + + await message.answer(_( + "admin_user_unban_success", + default="✅ Пользователь {input} разблокирован", + input=hcode(input_text) + )) + + except Exception as e: + logging.error(f"Error unbanning user {user_model.user_id}: {e}") + await session.rollback() + await message.answer(_( + "admin_user_unban_error", + default="❌ Ошибка разблокировки пользователя" + )) + + await state.clear() \ No newline at end of file From 283fc6bad430e91dde8257c7f90202526c83c69a Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 15:35:55 +0300 Subject: [PATCH 24/26] Update localization for user ban and unban messages in English and Russian - Added new messages for successful ban and unban actions, as well as error messages for banning and unbanning users. - Included a warning message for when a user is not banned, enhancing clarity in the admin panel's user management features. --- locales/en.json | 5 +++++ locales/ru.json | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/locales/en.json b/locales/en.json index 3226fd0..bbbf702 100644 --- a/locales/en.json +++ b/locales/en.json @@ -263,6 +263,11 @@ "admin_user_subscription_added_error": "❌ Error adding subscription days", "admin_user_ban_toggle_success": "✅ User {status}", "admin_user_ban_toggle_error": "❌ Error changing ban status", + "admin_user_ban_success": "✅ User {input} has been banned", + "admin_user_unban_success": "✅ User {input} has been unbanned", + "admin_user_ban_error": "❌ Error banning user", + "admin_user_unban_error": "❌ Error unbanning user", + "admin_user_not_banned": "⚠️ User is not banned", "admin_user_send_message_prompt": "✉️ Sending message to user {user_id}\n\nEnter message text:", "admin_user_message_too_long": "❌ Message too long (maximum 4000 characters)", "admin_user_message_sent_success": "✅ Message sent to user {user_id}", diff --git a/locales/ru.json b/locales/ru.json index a0389c8..623f981 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -263,6 +263,11 @@ "admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки", "admin_user_ban_toggle_success": "✅ Пользователь {status}", "admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки", + "admin_user_ban_success": "✅ Пользователь {input} заблокирован", + "admin_user_unban_success": "✅ Пользователь {input} разблокирован", + "admin_user_ban_error": "❌ Ошибка блокировки пользователя", + "admin_user_unban_error": "❌ Ошибка разблокировки пользователя", + "admin_user_not_banned": "⚠️ Пользователь не заблокирован", "admin_user_send_message_prompt": "✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", "admin_user_message_too_long": "❌ Сообщение слишком длинное (максимум 4000 символов)", "admin_user_message_sent_success": "✅ Сообщение отправлено пользователю {user_id}", From e5372032094e5253079ac8b6a75d463d9acd5584 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 23:21:12 +0300 Subject: [PATCH 25/26] Add auto-renewal and cancellation handling for tribute subscriptions - Implemented a new method to handle expired tribute subscriptions, automatically renewing them if no cancellation was received. - Added functionality to manage tribute subscription cancellations, setting a grace period and notifying users accordingly. - Updated localization files to include messages for auto-renewal and cancellation notifications in both English and Russian, enhancing user communication. --- bot/services/panel_webhook_service.py | 67 +++++++++++++++++++++++++++ bot/services/tribute_service.py | 65 ++++++++++++++++++++++++++ db/dal/payment_dal.py | 14 ++++++ db/dal/subscription_dal.py | 10 ++++ locales/en.json | 2 + locales/ru.json | 2 + 6 files changed, 160 insertions(+) diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index ca5a426..818c04e 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -41,6 +41,70 @@ class PanelWebhookService: except Exception as e: logging.error(f"Failed to send notification to {user_id}: {e}") + async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict, + lang: str, markup, first_name: str): + """Handle expired subscription - auto-renew tribute users if no cancellation was received""" + from db.dal import subscription_dal, payment_dal + from datetime import datetime, timezone, timedelta + + try: + # Check if user has tribute subscriptions that weren't cancelled + user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) + + for sub in user_subs: + # Check if this subscription was marked as cancelled (from tribute cancellation webhook) + if sub.status_from_panel == 'CANCELLED': + logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal") + continue + + # Check if this user has tribute payments + last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id) + + if last_tribute_duration is not None: + # This user has tribute payments, auto-renew for the same duration + logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months") + + # Extend subscription by the last payment duration + new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30) + + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': new_end_date, + 'status_from_panel': 'ACTIVE', + 'is_active': True + } + ) + + # Send auto-renewal notification + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + auto_renewal_msg = _( + "tribute_auto_renewal", + default="🔄 Подписка автоматически продлена\n\n" + "Ваша подписка Tribute была автоматически продлена на {months} мес.\n" + "Новая дата окончания: {end_date}", + user_name=first_name, + months=last_tribute_duration, + end_date=new_end_date.strftime('%Y-%m-%d') + ) + + try: + await self.bot.send_message( + user_id, + auto_renewal_msg, + reply_markup=markup, + parse_mode="HTML" + ) + except Exception as e: + logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}") + + await session.commit() + + except Exception as e: + logging.error(f"Error handling expired subscription for user {user_id}: {e}") + await session.rollback() + async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -70,6 +134,9 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + # Check if this is a tribute user that should be auto-renewed + await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) + await self._send_message( user_id, lang, diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index f310f2d..6e2029a 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -201,10 +201,75 @@ class TributeService: ) except Exception as e: logging.error(f"Failed to send tribute payment notification: {e}") + + elif event_name == 'subscription_cancelled': + # Handle tribute subscription cancellation + await self._handle_tribute_cancellation(session, user_id, bot, i18n) + else: await session.commit() return web.Response(status=200, text="ok") + async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n): + """Handle tribute subscription cancellation - set subscription to 1 day grace period""" + from datetime import datetime, timezone, timedelta + from db.dal import subscription_dal, user_dal + from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup + + try: + # Set all user's subscriptions to expire in 1 day (grace period) + grace_end_date = datetime.now(timezone.utc) + timedelta(days=1) + + # Get all active subscriptions for the user + user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) + + for sub in user_subs: + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': grace_end_date, + 'status_from_panel': 'CANCELLED', + 'skip_notifications': True # Skip future notifications for cancelled subs + } + ) + + await session.commit() + + # Send notification about cancellation if enabled + if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: + db_user = await user_dal.get_user_by_id(session, user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" + + _ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k + markup = get_subscribe_only_markup(lang, i18n) + + cancellation_msg = _( + "tribute_subscription_cancelled", + default="🚨 Подписка отменена\n\n" + "Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, " + "после чего подписка будет заблокирована.\n\n" + "Для продления подписки нажмите кнопку ниже.", + user_name=first_name + ) + + try: + await bot.send_message( + user_id, + cancellation_msg, + reply_markup=markup, + parse_mode="HTML" + ) + except Exception as e: + logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}") + + logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day") + + except Exception as e: + logging.error(f"Error handling tribute cancellation for user {user_id}: {e}") + await session.rollback() + async def tribute_webhook_route(request: web.Request): """AIOHTTP route handler for Tribute webhook calls.""" diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 6f87e82..394e43a 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -170,3 +170,17 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: "all_time_revenue": float(all_amount), "today_payments_count": today_payments_count } + + +async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]: + """Get duration in months from the last successful tribute payment for a user.""" + stmt = select(Payment.subscription_duration_months).where( + and_( + Payment.user_id == user_id, + Payment.provider == 'tribute', + Payment.status == 'succeeded' + ) + ).order_by(Payment.created_at.desc()).limit(1) + + result = await session.execute(stmt) + return result.scalar_one_or_none() diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index f67a2eb..91ede4a 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -31,6 +31,16 @@ async def get_subscription_by_panel_subscription_uuid( return result.scalar_one_or_none() +async def get_active_subscriptions_for_user(session: AsyncSession, user_id: int) -> List[Subscription]: + """Get all active subscriptions for a user.""" + stmt = select(Subscription).where( + Subscription.user_id == user_id, + Subscription.is_active == True + ).order_by(Subscription.end_date.desc()) + result = await session.execute(stmt) + return result.scalars().all() + + async def update_subscription( session: AsyncSession, subscription_id: int, update_data: Dict[str, Any]) -> Optional[Subscription]: diff --git a/locales/en.json b/locales/en.json index bbbf702..6b8405c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -232,6 +232,8 @@ "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", + "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", + "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", "admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.", diff --git a/locales/ru.json b/locales/ru.json index 623f981..9f50ed9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -232,6 +232,8 @@ "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", + "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", + "tribute_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.", From 04e45096be9212a2f4be7bd50426db289eb7ed18 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Tue, 5 Aug 2025 23:33:46 +0300 Subject: [PATCH 26/26] Update .env.example and settings.py for tribute payment configuration - Revised the .env.example file to include updated settings for Telegram bot, database, and payment gateways, enhancing clarity for configuration. - Added new fields for managing tribute payment notifications in settings.py, allowing for better control over cancellation notifications. - Improved organization of payment method settings and localization options for a more streamlined user experience. --- .env.example | 144 +++++++++++++++++++++++---------------------- config/settings.py | 1 + 2 files changed, 75 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 4c9e53b..9d7cc33 100644 --- a/.env.example +++ b/.env.example @@ -1,69 +1,72 @@ -# Telegram Bot -BOT_TOKEN= -ADMIN_IDS= -DEFAULT_LANGUAGE=ru -DEFAULT_CURRENCY_SYMBOL=RUB -SUPPORT_LINK= -SERVER_STATUS_URL= -TERMS_OF_SERVICE_URL= +# Telegram Bot Token and Admin IDs +BOT_TOKEN=your_bot_token_here +ADMIN_IDS=comma_separated_admin_ids -# Database -POSTGRES_USER=user -POSTGRES_PASSWORD=password -POSTGRES_HOST=db +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=remnawave-tg-shop-db POSTGRES_PORT=5432 -POSTGRES_DB=vpn_shop_db +POSTGRES_DB=postgres -# Webhooks -WEBHOOK_BASE_URL= -PANEL_WEBHOOK_SECRET= +# Localization and Display +DEFAULT_LANGUAGE="ru" # or "en" +DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR -# YooKassa -YOOKASSA_ENABLED=True -YOOKASSA_SHOP_ID= -YOOKASSA_SECRET_KEY= -YOOKASSA_RETURN_URL= -YOOKASSA_DEFAULT_RECEIPT_EMAIL= +# External Links +SUPPORT_LINK=https://t.me/your_support_link +SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service +TERMS_OF_SERVICE_URL=https://example.com/tos +SUBSCRIPTION_MINI_APP_URL= +START_COMMAND_DESCRIPTION= + +# Webhook Base URL (used for Telegram and payment providers) +WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld + +# YooKassa Payment Gateway Configuration +YOOKASSA_SHOP_ID=your_shop_id +YOOKASSA_SECRET_KEY=your_secret_key +YOOKASSA_RETURN_URL=https://t.me/your_bot +YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com YOOKASSA_VAT_CODE=1 YOOKASSA_PAYMENT_MODE=full_prepayment -YOOKASSA_PAYMENT_SUBJECT=service +YOOKASSA_PAYMENT_SUBJECT=payment -# CryptoPay -CRYPTOPAY_ENABLED=True +# CryptoBot Payment Gateway Configuration CRYPTOPAY_TOKEN= CRYPTOPAY_NETWORK=mainnet CRYPTOPAY_CURRENCY_TYPE=fiat CRYPTOPAY_ASSET=RUB -# Telegram Stars +# Tribute Payment Gateway Configuration +TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures +TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments +TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments + +# Payment Method Toggles +YOOKASSA_ENABLED=True STARS_ENABLED=True - -# Telegram Tribute TRIBUTE_ENABLED=True -TRIBUTE_API_KEY= +CRYPTOPAY_ENABLED=True -# Subscription Durations +# Subscription Options 1_MONTH_ENABLED=True -3_MONTHS_ENABLED=True -6_MONTHS_ENABLED=True -12_MONTHS_ENABLED=True - -# Prices in RUB -RUB_PRICE_1_MONTH= -RUB_PRICE_3_MONTHS= -RUB_PRICE_6_MONTHS= -RUB_PRICE_12_MONTHS= - -# Prices in Telegram Stars -STARS_PRICE_1_MONTH= -STARS_PRICE_3_MONTHS= -STARS_PRICE_6_MONTHS= -STARS_PRICE_12_MONTHS= - -# Tribute Payment Links +RUB_PRICE_1_MONTH=150 +STARS_PRICE_1_MONTH=0 TRIBUTE_LINK_1_MONTH= + +3_MONTHS_ENABLED=True +RUB_PRICE_3_MONTHS=300 +STARS_PRICE_3_MONTHS=0 TRIBUTE_LINK_3_MONTHS= + +6_MONTHS_ENABLED=True +RUB_PRICE_6_MONTHS=500 +STARS_PRICE_6_MONTHS=0 TRIBUTE_LINK_6_MONTHS= + +12_MONTHS_ENABLED=True +RUB_PRICE_12_MONTHS=900 +STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= # Subscription Notifications @@ -72,51 +75,52 @@ SUBSCRIPTION_NOTIFY_ON_EXPIRE=True SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 -# Referral Bonuses (Inviter) +# Referral Bonus Days REFERRAL_BONUS_DAYS_1_MONTH=3 REFERRAL_BONUS_DAYS_3_MONTHS=7 REFERRAL_BONUS_DAYS_6_MONTHS=15 REFERRAL_BONUS_DAYS_12_MONTHS=30 - -# Referral Bonuses (Referee) REFEREE_BONUS_DAYS_1_MONTH=1 REFEREE_BONUS_DAYS_3_MONTHS=3 REFEREE_BONUS_DAYS_6_MONTHS=7 REFEREE_BONUS_DAYS_12_MONTHS=15 -# Panel Integration -PANEL_API_URL= -PANEL_API_KEY= +# Panel API Configuration +PANEL_API_URL=http://your_panel_api_url/api +PANEL_API_KEY=your_panel_api_key +PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures + +# User traffic limits (applied for all users) +# 0 means unlimited USER_TRAFFIC_LIMIT_GB=0 -USER_TRAFFIC_STRATEGY=NO_RESET -USER_SQUAD_UUIDS= +USER_TRAFFIC_STRATEGY="NO_RESET" -# Trial Period +# Default Internal Squads for Users (Optional, comma-separated UUIDs) +USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 + +# Trial Settings TRIAL_ENABLED=True -TRIAL_DURATION_DAYS=3 -TRIAL_TRAFFIC_LIMIT_GB=5.0 +TRIAL_DURATION_DAYS=5 +TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited +TRIAL_TRAFFIC_STRATEGY="NO_RESET" -# Web Server -WEB_SERVER_HOST=0.0.0.0 +# Web Server Settings (for handling webhooks) +WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_PORT=8080 + +# Admin Panel Log Pagination LOGS_PAGE_SIZE=10 -# Mini App -SUBSCRIPTION_MINI_APP_URL= - -# Customization -START_COMMAND_DESCRIPTION= - -# Logging -LOG_CHAT_ID= -LOG_THREAD_ID= +# Admin Logging Configuration +LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications +LOG_THREAD_ID= # Optional: Thread ID for supergroup messages LOG_NEW_USERS=True LOG_PAYMENTS=True LOG_PROMO_ACTIVATIONS=True LOG_TRIAL_ACTIVATIONS=True LOG_SUSPICIOUS_ACTIVITY=True -# Inline Thumbnails +# Inline Mode Thumbnails INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png diff --git a/config/settings.py b/config/settings.py index 8b7a07a..aec0979 100644 --- a/config/settings.py +++ b/config/settings.py @@ -67,6 +67,7 @@ class Settings(BaseSettings): TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None) TRIBUTE_API_KEY: Optional[str] = Field(default=None) TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments") + TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments") PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)