From e13937b965cc0ee4c966ced6ef9a84f37113959d Mon Sep 17 00:00:00 2001 From: hteppl Date: Thu, 9 Jan 2025 15:13:36 +0300 Subject: [PATCH 1/7] Introducing keyboards package --- keyboards/__init__.py | 0 keyboards/admin/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 keyboards/__init__.py create mode 100644 keyboards/admin/__init__.py diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keyboards/admin/__init__.py b/keyboards/admin/__init__.py new file mode 100644 index 00000000..e69de29b From ba53e9e199a1edecc602523a65175911fa09f33f Mon Sep 17 00:00:00 2001 From: hteppl Date: Thu, 9 Jan 2025 15:43:22 +0300 Subject: [PATCH 2/7] Update admin main panel --- handlers/admin/admin_panel.py | 542 ++-------------------------------- keyboards/admin/panel_kb.py | 80 +++++ 2 files changed, 99 insertions(+), 523 deletions(-) create mode 100644 keyboards/admin/panel_kb.py diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py index 88a69282..3df31a34 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -1,544 +1,40 @@ -import subprocess -from datetime import datetime -from io import BytesIO -from typing import Any - -import asyncpg from aiogram import F, Router, types from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import CallbackQuery -from backup import backup_database -from bot import bot -from config import DATABASE_URL from filters.admin import IsAdminFilter -from logger import logger -from database import delete_user_data +from keyboards.admin.panel_kb import build_panel_kb, AdminPanelCallback, build_management_kb router = Router() -class UserEditorState(StatesGroup): - waiting_for_tg_id = State() - displaying_user_info = State() - waiting_for_restart_confirmation = State() - waiting_for_message = State() - - -@router.callback_query(F.data == "admin", IsAdminFilter()) +@router.callback_query( + F.data == "admin", + IsAdminFilter() +) async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext): await handle_admin_message(callback_query.message, state) -@router.message(Command("admin"), F.data == "admin", IsAdminFilter()) +@router.message( + Command("admin"), + IsAdminFilter() +) async def handle_admin_message(message: types.Message, state: FSMContext): await state.clear() - - BOT_VERSION = "4.0.0-preAlpha" # Укажите текущую версию бота - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="📊 Статистика пользователей", callback_data="user_stats" - ) - ) - builder.row( - InlineKeyboardButton( - text="👥 Управление пользователями", callback_data="user_editor" - ) - ) - builder.row( - InlineKeyboardButton( - text="🖥️ Управление серверами", callback_data="servers_editor" - ) - ) - builder.row( - InlineKeyboardButton( - text="🎟️ Управление купонами", callback_data="coupons_editor" - ) - ) - builder.row( - InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to") - ) - builder.row( - InlineKeyboardButton( - text="🤖 Управление Ботом", callback_data="bot_management" - ) - ) - builder.row( - InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile") - ) - await message.answer( - f"🤖 Панель администратора\n\nВерсия бота: {BOT_VERSION}", - reply_markup=builder.as_markup(), - parse_mode="HTML" - ) - - -@router.callback_query(F.data == "bot_management") -async def handle_bot_management(callback_query: types.CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups") - ) - builder.row( - InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot") - ) - builder.row( - InlineKeyboardButton(text="🚫 Баны", callback_data="ban_user") - ) - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="admin") - ) - await callback_query.message.answer( - "🤖 Управление ботом", - reply_markup=builder.as_markup(), - ) - - - -@router.callback_query(F.data == "user_stats", IsAdminFilter()) -async def user_stats_menu(callback_query: CallbackQuery, session: Any): - try: - total_users = await session.fetchval("SELECT COUNT(*) FROM connections") - total_keys = await session.fetchval("SELECT COUNT(*) FROM keys") - total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals") - - total_payments_today = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE" - ) - total_payments_week = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)" - ) - total_payments_month = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)" - ) - total_payments_all_time = await session.fetchval( - "SELECT COALESCE(SUM(amount), 0) FROM payments" - ) - - active_keys = await session.fetchval( - "SELECT COUNT(*) FROM keys WHERE expiry_time > $1", - int(datetime.utcnow().timestamp() * 1000), - ) - expired_keys = total_keys - active_keys - - stats_message = ( - f"📊 Подробная статистика проекта:\n\n" - f"👥 Пользователи:\n" - f" 🌐 Зарегистрировано: {total_users}\n" - f" 🤝 Привлеченных рефералов: {total_referrals}\n\n" - f"🔑 Ключи:\n" - f" 🌈 Всего сгенерировано: {total_keys}\n" - f" ✅ Действующих: {active_keys}\n" - f" ❌ Просроченных: {expired_keys}\n\n" - f"💰 Финансовая статистика:\n" - f" 📅 За день: {total_payments_today} ₽\n" - f" 📆 За неделю: {total_payments_week} ₽\n" - f" 📆 За месяц: {total_payments_month} ₽\n" - f" 🏦 За все время: {total_payments_all_time} ₽\n" - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats") - ) - builder.row( - InlineKeyboardButton( - text="📥 Выгрузить пользователей в CSV", - callback_data="export_users_csv", - ) - ) - builder.row( - InlineKeyboardButton( - text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv" - ) - ) - builder.row( - InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin") - ) - - await callback_query.message.answer( - stats_message, reply_markup=builder.as_markup() - ) - except Exception as e: - logger.error(f"Error in user_stats_menu: {e}") - - -@router.callback_query(F.data == "export_users_csv", IsAdminFilter()) -async def export_users_csv(callback_query: CallbackQuery, session: Any): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats")) - try: - users = await session.fetch( - """ - SELECT - u.tg_id, - u.username, - u.first_name, - u.last_name, - u.language_code, - u.is_bot, - c.balance, - c.trial - FROM users u - LEFT JOIN connections c ON u.tg_id = c.tg_id - """ - ) - - if not users: - await callback_query.message.answer( - "📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup() - ) - return - - csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n" - for user in users: - csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n" - - file_name = BytesIO(csv_data.encode("utf-8-sig")) - file_name.seek(0) - - file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv") - - await callback_query.message.answer_document( - file, - caption="📥 Экспорт пользователей в CSV", - reply_markup=builder.as_markup(), - ) - file_name.close() - - except Exception as e: - logger.error(f"Ошибка при экспорте пользователей в CSV: {e}") - await callback_query.message.answer( - "❗ Произошла ошибка при экспорте пользователей.", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "export_payments_csv", IsAdminFilter()) -async def export_payments_csv(callback_query: CallbackQuery, session: Any): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats")) - try: - payments = await session.fetch( - """ - SELECT - u.tg_id, - u.username, - u.first_name, - u.last_name, - p.amount, - p.payment_system, - p.status, - p.created_at - FROM users u - JOIN payments p ON u.tg_id = p.tg_id - """ - ) - - if not payments: - await callback_query.message.answer( - "📭 Нет платежей для экспорта.", reply_markup=builder.as_markup() - ) - return - - csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV - for payment in payments: - csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n" - - file_name = BytesIO(csv_data.encode("utf-8-sig")) - file_name.seek(0) - - file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv") - - await callback_query.message.answer_document( - file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup() - ) - file_name.close() - - except Exception as e: - logger.error(f"Ошибка при экспорте платежей в CSV: {e}") - await callback_query.message.answer( - "❗ Произошла ошибка при экспорте платежей.", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "send_to", IsAdminFilter()) -async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="📢 Отправить всем", callback_data="send_to_all")) - builder.row(InlineKeyboardButton(text="📢 Отправить с подпиской", callback_data="send_to_subscribed")) - builder.row(InlineKeyboardButton(text="📢 Отправить без подписки", callback_data="send_to_unsubscribed")) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin")) - await callback_query.message.answer( - "✍️ Выберите группу пользователей и введите текст сообщения для рассылки:", - reply_markup=builder.as_markup(), - ) - -@router.callback_query(F.data == "send_to_all", IsAdminFilter()) -async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="all") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки всем пользователям:" - ) - await state.set_state(UserEditorState.waiting_for_message) - -@router.callback_query(F.data == "send_to_subscribed", IsAdminFilter()) -async def handle_send_to_subscribed(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="subscribed") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки пользователям с активной подпиской:" - ) - await state.set_state(UserEditorState.waiting_for_message) - -@router.callback_query(F.data == "send_to_unsubscribed", IsAdminFilter()) -async def handle_send_to_unsubscribed(callback_query: CallbackQuery, state: FSMContext): - await state.update_data(send_to="unsubscribed") - await callback_query.message.answer( - "✍️ Введите текст сообщения для рассылки пользователям без активной подписки:" - ) - await state.set_state(UserEditorState.waiting_for_message) - - -@router.message(UserEditorState.waiting_for_message, IsAdminFilter()) -async def process_message_to_all( - message: types.Message, state: FSMContext, session: Any -): - text_message = message.text - - try: - state_data = await state.get_data() - send_to = state_data.get('send_to', 'all') - - if send_to == 'all': - tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections") - elif send_to == 'subscribed': - tg_ids = await session.fetch(""" - SELECT DISTINCT c.tg_id - FROM connections c - JOIN keys k ON c.tg_id = k.tg_id - WHERE k.expiry_time > $1 - """, int(datetime.utcnow().timestamp() * 1000)) - elif send_to == 'unsubscribed': - tg_ids = await session.fetch(""" - SELECT c.tg_id - FROM connections c - LEFT JOIN keys k ON c.tg_id = k.tg_id - GROUP BY c.tg_id - HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1 - """, int(datetime.utcnow().timestamp() * 1000)) - - total_users = len(tg_ids) - success_count = 0 - error_count = 0 - - for record in tg_ids: - tg_id = record["tg_id"] - try: - await bot.send_message(chat_id=tg_id, text=text_message) - success_count += 1 - except Exception as e: - error_count += 1 - logger.error( - f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}" - ) - - await message.answer( - f"📤 Рассылка завершена:\n" - f"👥 Всего пользователей: {total_users}\n" - f"✅ Успешно отправлено: {success_count}\n" - f"❌ Не доставлено: {error_count}" - ) - except Exception as e: - logger.error(f"❗ Ошибка при подключении к базе данных: {e}") - - await state.clear() - - -@router.callback_query(F.data == "backups", IsAdminFilter()) -async def handle_backup(callback_query: CallbackQuery, state: FSMContext): - await callback_query.message.answer( - "💾 Инициализация резервного копирования базы данных..." - ) - await backup_database() - await callback_query.message.answer( - "✅ Резервная копия успешно создана и отправлена администратору." - ) - - -@router.callback_query(F.data == "restart_bot", IsAdminFilter()) -async def handle_restart(callback_query: CallbackQuery, state: FSMContext): - await state.set_state(UserEditorState.waiting_for_restart_confirmation) - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="✅ Да, перезапустить", callback_data="confirm_restart" - ), - InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"), - ) - builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")) - await callback_query.message.answer( - "🤔 Вы уверены, что хотите перезапустить бота?", - reply_markup=builder.as_markup(), + await message.message.answer( + text="🤖 Панель администратора", + reply_markup=build_panel_kb() ) @router.callback_query( - F.data == "confirm_restart", - UserEditorState.waiting_for_restart_confirmation, - IsAdminFilter(), + AdminPanelCallback.filter(F.action == "management"), + IsAdminFilter() ) -async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")) - try: - subprocess.run( - ["sudo", "systemctl", "restart", "bot.service"], - check=True, - capture_output=True, - text=True, - ) - await state.clear() - await callback_query.message.answer( - "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup() - ) - except subprocess.CalledProcessError: - await callback_query.message.answer( - "🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup() - ) - except Exception as e: - await callback_query.message.answer( - f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "user_editor", IsAdminFilter()) -async def user_editor_menu(callback_query: CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton( - text="🔍 Поиск по названию ключа", - callback_data="search_by_key_name", - ) +async def handle_restart(callback_query: CallbackQuery): + await callback_query.message.edit_text( + text="🤖 Управление ботом", + reply_markup=build_management_kb(), ) - builder.row( - InlineKeyboardButton( - text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id" - ) - ) - builder.row( - InlineKeyboardButton( - text="🌐 Поиск по Username", callback_data="search_by_username" - ) - ) - builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin")) - await callback_query.message.answer( - "👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup() - ) - - -@router.callback_query(F.data == "ban_user") -async def handle_ban_user(callback_query: types.CallbackQuery): - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="📄 Выгрузить в CSV", callback_data="export_to_csv") - ) - builder.row( - InlineKeyboardButton(text="🗑️ Удалить из БД", callback_data="delete_banned_users") - ) - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - "🚫 Заблокировавшие бота\n\n" - "Здесь можно просматривать и удалять пользователей, которые забанили вашего бота!", - reply_markup=builder.as_markup(), - ) - - -@router.callback_query(F.data == "export_to_csv") -async def export_banned_users_to_csv(callback_query: types.CallbackQuery): - conn = await asyncpg.connect(DATABASE_URL) - try: - banned_users = await conn.fetch("SELECT tg_id, blocked_at FROM blocked_users") - - import csv - import io - csv_output = io.StringIO() - writer = csv.writer(csv_output) - writer.writerow(["tg_id", "blocked_at"]) - for user in banned_users: - writer.writerow([user["tg_id"], user["blocked_at"]]) - - csv_output.seek(0) - - document = BufferedInputFile( - file=csv_output.getvalue().encode("utf-8"), - filename="banned_users.csv" - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - - await callback_query.message.answer_document( - document=document, - caption="📄 Список заблокировавших бота пользователей", - reply_markup=builder.as_markup(), - ) - except Exception as e: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"Ошибка при выгрузке CSV: {e}", - reply_markup=builder.as_markup(), - ) - finally: - await conn.close() - - -@router.callback_query(F.data == "delete_banned_users") -async def delete_banned_users(callback_query: types.CallbackQuery): - conn = await asyncpg.connect(DATABASE_URL) - try: - blocked_users = await conn.fetch("SELECT tg_id FROM blocked_users") - blocked_ids = [record['tg_id'] for record in blocked_users] - - if not blocked_ids: - await callback_query.message.answer("📂 Нет заблокировавших пользователей для удаления.") - return - - for tg_id in blocked_ids: - await delete_user_data(conn, tg_id) - - await conn.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"🗑️ Удалено данные о {len(blocked_ids)} пользователях и связанных записях.", - reply_markup=builder.as_markup(), - ) - except Exception as e: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="⬅️ Назад", callback_data="bot_management") - ) - await callback_query.message.answer( - text=f"Ошибка при удалении записей: {e}", - reply_markup=builder.as_markup(), - ) - finally: - await conn.close() - diff --git a/keyboards/admin/panel_kb.py b/keyboards/admin/panel_kb.py new file mode 100644 index 00000000..3c517d48 --- /dev/null +++ b/keyboards/admin/panel_kb.py @@ -0,0 +1,80 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +class AdminPanelCallback(CallbackData, prefix='admin_panel'): + action: str + + +def build_panel_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🔍 Поиск пользователя", + callback_data=AdminPanelCallback(action="search_user").pack() + ) + builder.button( + text="🔑 Поиск по названию ключа", + callback_data=AdminPanelCallback(action="search_key").pack() + ) + builder.row( + InlineKeyboardButton( + text="🖥️ Серверы", + callback_data=AdminPanelCallback(action="servers").pack() + ), + InlineKeyboardButton( + text="🎟️ Купоны", + callback_data=AdminPanelCallback(action="coupons").pack() + ) + ) + builder.button( + text="📢 Рассылка", + callback_data=AdminPanelCallback(action="sender").pack() + ) + builder.button( + text="🤖 Управление Ботом", + callback_data=AdminPanelCallback(action="management").pack() + ) + builder.button( + text="📊 Статистика", + callback_data=AdminPanelCallback(action="stats").pack() + ) + builder.button( + text="👤 Личный кабинет", + callback_data="profile" + ) + return builder.as_markup() + + +def build_management_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="💾 Создать резервную копию", + callback_data=AdminPanelCallback(action="backups").pack() + ) + builder.button( + text="🚫 Баны", + callback_data=AdminPanelCallback(action="bans").pack() + ) + builder.button( + text="🔄 Перезагрузить бота", + callback_data=AdminPanelCallback(action="restart").pack() + ) + builder.button( + text="⬅️ Назад", + callback_data="admin" + ) + return builder.as_markup() + + +def build_restart_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="✅ Да, перезапустить", + callback_data=AdminPanelCallback(action="restart_confirm").pack() + ) + builder.button( + text="🔙 Назад", + callback_data="admin" + ) + return builder.as_markup() From 73bf682f9779d926a01f28bb9b3ed05603f31f50 Mon Sep 17 00:00:00 2001 From: hteppl Date: Thu, 9 Jan 2025 15:48:32 +0300 Subject: [PATCH 3/7] Update admin coupons handler --- handlers/admin/admin_coupons.py | 228 +++++++++++++++++--------------- keyboards/admin/coupons_kb.py | 43 ++++++ keyboards/admin/panel_kb.py | 10 ++ 3 files changed, 176 insertions(+), 105 deletions(-) create mode 100644 keyboards/admin/coupons_kb.py diff --git a/handlers/admin/admin_coupons.py b/handlers/admin/admin_coupons.py index 27e3071e..ddcbf67b 100644 --- a/handlers/admin/admin_coupons.py +++ b/handlers/admin/admin_coupons.py @@ -3,56 +3,141 @@ from typing import Any from aiogram import F, Router, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.types import InlineKeyboardButton -from aiogram.utils.keyboard import InlineKeyboardBuilder -from database import create_coupon, delete_coupon_from_db, get_all_coupons +from database import delete_coupon_from_db, get_all_coupons, create_coupon from filters.admin import IsAdminFilter +from keyboards.admin.coupons_kb import build_coupons_kb, build_coupons_list_kb, AdminCouponDeleteCallback +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb from logger import logger +router = Router() + class AdminCouponsState(StatesGroup): waiting_for_coupon_data = State() -router = Router() - - -@router.callback_query(F.data == "coupons_editor", IsAdminFilter()) +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons"), + IsAdminFilter(), +) async def show_coupon_management_menu( - callback_query: types.CallbackQuery, state: FSMContext + callback_query: types.CallbackQuery, + state: FSMContext ): await state.clear() - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon") - ) - builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons")) - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin")) + + kb = build_coupons_kb() + await callback_query.message.answer( - "🛠 Меню управления купонами:", reply_markup=builder.as_markup() + text="🛠 Меню управления купонами:", + reply_markup=kb ) -@router.callback_query(F.data == "coupons", IsAdminFilter()) -async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons_create"), + IsAdminFilter(), +) +async def handle_create_coupon( + callback_query: types.CallbackQuery, + state: FSMContext +): + text = ( + "🎫 Введите данные для создания купона в формате:\n\n" + "📝 код 💰 сумма 🔢 лимит\n\n" + "Пример: 'COUPON1 50 5' 👈\n\n" + ) + + await callback_query.message.answer( + text=text, + reply_markup=build_admin_back_kb("coupons"), + ) + await state.set_state(AdminCouponsState.waiting_for_coupon_data) + + +@router.message( + AdminCouponsState.waiting_for_coupon_data, + IsAdminFilter() +) +async def process_coupon_data( + message: types.Message, + state: FSMContext, + session: Any +): + text = message.text.strip() + parts = text.split() + + kb = build_admin_back_kb("coupons") + + if len(parts) != 3: + text = ( + "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n" + "🏷️ код 💰 сумма 🔢 лимит\n" + "Пример: 'COUPON1 50 5' 👈" + ) + + await message.answer( + text=text, + reply_markup=kb, + ) + return + + try: + coupon_code = parts[0] + coupon_amount = float(parts[1]) + usage_limit = int(parts[2]) + except ValueError: + text = ( + "⚠️ Проверьте правильность введенных данных!\n" + "💱 Сумма должна быть числом, а лимит — целым числом." + ) + + await message.answer( + text=text, + reply_markup=kb, + ) + return + + try: + await create_coupon(coupon_code, coupon_amount, usage_limit, session) + + result_message = ( + f"✅ Купон с кодом {coupon_code} успешно создан!\n" + f"💰 Сумма: {coupon_amount} рублей \n" + f"🔢 Лимит использования: {usage_limit} раз" + ) + + await message.answer( + text=result_message, + reply_markup=kb + ) + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при создании купона: {e}") + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "coupons_list"), + IsAdminFilter(), +) +async def show_coupon_list( + callback_query: types.CallbackQuery, + session: Any +): try: coupons = await get_all_coupons(session) if not coupons: - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) - await callback_query.message.answer( - "❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙", - reply_markup=builder.as_markup(), + text="❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙", + reply_markup=build_admin_back_kb("coupons"), ) return + kb = build_coupons_list_kb(coupons) coupon_list = "📜 Список всех купонов:\n\n" - builder = InlineKeyboardBuilder() for coupon in coupons: coupon_list += ( @@ -62,27 +147,25 @@ async def show_coupon_list(callback_query: types.CallbackQuery, session: Any): f"✅ Использовано: {coupon['usage_count']} раз\n\n" ) - builder.row( - InlineKeyboardButton( - text=f"❌ Удалить {coupon['code']}", - callback_data=f"delete_coupon_{coupon['code']}", - ) - ) - - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) await callback_query.message.answer( - coupon_list, reply_markup=builder.as_markup() + text=coupon_list, + reply_markup=kb ) except Exception as e: logger.error(f"Ошибка при получении списка купонов: {e}") -@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter()) -async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any): - coupon_code = callback_query.data[len("delete_coupon_") :] +@router.callback_query( + AdminCouponDeleteCallback.filter(), + IsAdminFilter(), +) +async def handle_delete_coupon( + callback_query: types.CallbackQuery, + callback_data: AdminCouponDeleteCallback, + session: Any +): + coupon_code = callback_data.coupon_code try: result = await delete_coupon_from_db(coupon_code, session) @@ -91,74 +174,9 @@ async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any await show_coupon_list(callback_query, session) else: await callback_query.message.answer( - f"❌ Купон с кодом {coupon_code} не найден.", + text=f"❌ Купон с кодом {coupon_code} не найден.", ) await show_coupon_list(callback_query, session) except Exception as e: logger.error(f"Ошибка при удалении купона: {e}") - - -@router.callback_query(F.data == "create_coupon", IsAdminFilter()) -async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext): - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")) - - await callback_query.message.answer( - "🎫 Введите данные для создания купона в формате:\n\n" - "📝 код 💰 сумма 🔢 лимит\n\n" - "Пример: 'COUPON1 50 5' 👈\n\n", - reply_markup=builder.as_markup(), - ) - await state.set_state(AdminCouponsState.waiting_for_coupon_data) - - -@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter()) -async def process_coupon_data(message: types.Message, state: FSMContext, session: Any): - text = message.text.strip() - - parts = text.split() - - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")) - - if len(parts) != 3: - await message.answer( - "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n" - "🏷️ код 💰 сумма 🔢 лимит\n" - "Пример: 'COUPON1 50 5' 👈", - reply_markup=builder.as_markup(), - ) - return - - try: - coupon_code = parts[0] - coupon_amount = float(parts[1]) - usage_limit = int(parts[2]) - except ValueError: - await message.answer( - "⚠️ Проверьте правильность введенных данных!\n" - "💱 Сумма должна быть числом, 🔢 а лимит — целым числом.", - reply_markup=builder.as_markup(), - ) - return - - try: - await create_coupon(coupon_code, coupon_amount, usage_limit, session) - - result_message = ( - f"✅ Купон с кодом {coupon_code} успешно создан! 🎉\n" - f"Сумма: {coupon_amount} рублей 💰\n" - f"Лимит использования: {usage_limit} раз 🔢." - ) - - builder = InlineKeyboardBuilder() - builder.row( - InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor") - ) - - await message.answer(result_message, reply_markup=builder.as_markup()) - await state.clear() - - except Exception as e: - logger.error(f"Ошибка при создании купона: {e}") diff --git a/keyboards/admin/coupons_kb.py b/keyboards/admin/coupons_kb.py new file mode 100644 index 00000000..25467a16 --- /dev/null +++ b/keyboards/admin/coupons_kb.py @@ -0,0 +1,43 @@ +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import AdminPanelCallback + + +class AdminCouponDeleteCallback(CallbackData, prefix='admin_coupon_delete'): + coupon_code: str + + +def build_coupons_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="➕ Создать купон", + callback_data=AdminPanelCallback(action="coupons_create").pack() + ) + builder.button( + text="Купоны", + callback_data=AdminPanelCallback(action="coupons_list").pack() + ) + builder.button( + text="🔙 Назад", + callback_data="admin" + ) + return builder.as_markup() + + +def build_coupons_list_kb(coupons: list) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for coupon in coupons: + coupon_code = coupon["code"] + builder.button( + text=f"❌ Удалить {coupon_code}", + callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code), + ) + + builder.button( + text="🔙 Назад", + callback_data=AdminPanelCallback(action="coupons").pack() + ) + return builder.as_markup() diff --git a/keyboards/admin/panel_kb.py b/keyboards/admin/panel_kb.py index 3c517d48..b8e9133c 100644 --- a/keyboards/admin/panel_kb.py +++ b/keyboards/admin/panel_kb.py @@ -78,3 +78,13 @@ def build_restart_kb() -> InlineKeyboardMarkup: callback_data="admin" ) return builder.as_markup() + + +def build_admin_back_kb(action: str) -> InlineKeyboardMarkup: + return build_admin_singleton_kb("🔙 Назад", action) + + +def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text=text, callback_data=AdminPanelCallback(action=action).pack()) + return builder.as_markup() From 8a15d0320cfeb5dc504feedc7824e1c2b3dafe6f Mon Sep 17 00:00:00 2001 From: hteppl Date: Thu, 9 Jan 2025 15:49:23 +0300 Subject: [PATCH 4/7] Implement admin backups handler --- handlers/admin/admin_backups.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 handlers/admin/admin_backups.py diff --git a/handlers/admin/admin_backups.py b/handlers/admin/admin_backups.py new file mode 100644 index 00000000..d287659b --- /dev/null +++ b/handlers/admin/admin_backups.py @@ -0,0 +1,22 @@ +from aiogram import F, Router +from aiogram.types import CallbackQuery + +from backup import backup_database +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import AdminPanelCallback + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "backups"), + IsAdminFilter(), +) +async def handle_backup(callback_query: CallbackQuery): + await callback_query.message.answer( + text="💾 Инициализация резервного копирования базы данных..." + ) + await backup_database() + await callback_query.message.answer( + text="✅ Резервная копия успешно создана и отправлена администратору." + ) From 0b0fca8648ed9014413c6f8158b945c4842d916e Mon Sep 17 00:00:00 2001 From: hteppl Date: Sat, 11 Jan 2025 02:17:02 +0300 Subject: [PATCH 5/7] Add more base functions for creating singleton admin keyboards --- keyboards/admin/panel_kb.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/keyboards/admin/panel_kb.py b/keyboards/admin/panel_kb.py index b8e9133c..93971469 100644 --- a/keyboards/admin/panel_kb.py +++ b/keyboards/admin/panel_kb.py @@ -60,9 +60,8 @@ def build_management_kb() -> InlineKeyboardMarkup: text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack() ) - builder.button( - text="⬅️ Назад", - callback_data="admin" + builder.row( + build_admin_back_btn("admin") ) return builder.as_markup() @@ -73,9 +72,8 @@ def build_restart_kb() -> InlineKeyboardMarkup: text="✅ Да, перезапустить", callback_data=AdminPanelCallback(action="restart_confirm").pack() ) - builder.button( - text="🔙 Назад", - callback_data="admin" + builder.row( + build_admin_back_btn("admin") ) return builder.as_markup() @@ -86,5 +84,15 @@ def build_admin_back_kb(action: str) -> InlineKeyboardMarkup: def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text=text, callback_data=AdminPanelCallback(action=action).pack()) + builder.row( + build_admin_btn(text, action) + ) return builder.as_markup() + + +def build_admin_back_btn(action: str) -> InlineKeyboardMarkup: + return build_admin_btn("🔙 Назад", action) + + +def build_admin_btn(text: str, action: str) -> InlineKeyboardButton: + return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack()) From c9664c136b99f1938236e572b709bf17052ebf42 Mon Sep 17 00:00:00 2001 From: hteppl Date: Sat, 11 Jan 2025 02:17:54 +0300 Subject: [PATCH 6/7] Implement admin restart handler + also handle AdminPanelCallback for back buttons --- handlers/admin/admin_panel.py | 8 ++++++ handlers/admin/admin_restart.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 handlers/admin/admin_restart.py diff --git a/handlers/admin/admin_panel.py b/handlers/admin/admin_panel.py index 3df31a34..d1ffbdf7 100644 --- a/handlers/admin/admin_panel.py +++ b/handlers/admin/admin_panel.py @@ -9,6 +9,14 @@ from keyboards.admin.panel_kb import build_panel_kb, AdminPanelCallback, build_m router = Router() +@router.callback_query( + AdminPanelCallback.filter(F.action == "admin"), + IsAdminFilter() +) +async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext): + await handle_admin_message(callback_query.message, state) + + @router.callback_query( F.data == "admin", IsAdminFilter() diff --git a/handlers/admin/admin_restart.py b/handlers/admin/admin_restart.py new file mode 100644 index 00000000..47cbc134 --- /dev/null +++ b/handlers/admin/admin_restart.py @@ -0,0 +1,50 @@ +import subprocess + +from aiogram import F, Router +from aiogram.types import CallbackQuery + +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import build_restart_kb, AdminPanelCallback, build_admin_back_kb + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "restart"), + IsAdminFilter(), +) +async def handle_restart(callback_query: CallbackQuery): + kb = build_restart_kb() + await callback_query.message.answer( + text="🤔 Вы уверены, что хотите перезапустить бота?", + reply_markup=kb, + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "restart_confirm"), + IsAdminFilter(), +) +async def confirm_restart_bot(callback_query: CallbackQuery): + kb = build_admin_back_kb("admin") + try: + subprocess.run( + ["sudo", "systemctl", "restart", "bot.service"], + check=True, + capture_output=True, + text=True, + ) + await callback_query.message.answer( + text="🔄 Бот успешно перезапущен.", + reply_markup=kb + ) + except subprocess.CalledProcessError: + await callback_query.message.answer( + text="🔄 Бот успешно перезапущен.", + reply_markup=kb + ) + except Exception as e: + await callback_query.message.answer( + text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", + reply_markup=kb + ) From 97d598a2938bc10331ea3a3dec43bd1117076726 Mon Sep 17 00:00:00 2001 From: hteppl Date: Sat, 11 Jan 2025 02:24:16 +0300 Subject: [PATCH 7/7] Implement admin stats handler --- handlers/admin/admin_stats.py | 178 ++++++++++++++++++++++++++++++++++ keyboards/admin/stats_kb.py | 24 +++++ 2 files changed, 202 insertions(+) create mode 100644 handlers/admin/admin_stats.py create mode 100644 keyboards/admin/stats_kb.py diff --git a/handlers/admin/admin_stats.py b/handlers/admin/admin_stats.py new file mode 100644 index 00000000..113a6caa --- /dev/null +++ b/handlers/admin/admin_stats.py @@ -0,0 +1,178 @@ +from datetime import datetime +from io import BytesIO +from typing import Any + +from aiogram import F, Router +from aiogram.types import CallbackQuery, BufferedInputFile + +from filters.admin import IsAdminFilter +from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb +from keyboards.admin.stats_kb import build_stats_kb +from logger import logger + +router = Router() + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats"), + IsAdminFilter(), +) +async def user_stats_menu(callback_query: CallbackQuery, session: Any): + try: + total_users = await session.fetchval("SELECT COUNT(*) FROM connections") + total_keys = await session.fetchval("SELECT COUNT(*) FROM keys") + total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals") + + total_payments_today = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE" + ) + total_payments_week = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)" + ) + total_payments_month = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)" + ) + total_payments_all_time = await session.fetchval( + "SELECT COALESCE(SUM(amount), 0) FROM payments" + ) + + active_keys = await session.fetchval( + "SELECT COUNT(*) FROM keys WHERE expiry_time > $1", + int(datetime.utcnow().timestamp() * 1000), + ) + expired_keys = total_keys - active_keys + + stats_message = ( + f"📊 Подробная статистика проекта:\n\n" + f"👥 Пользователи:\n" + f" 🌐 Зарегистрировано: {total_users}\n" + f" 🤝 Привлеченных рефералов: {total_referrals}\n\n" + f"🔑 Ключи:\n" + f" 🌈 Всего сгенерировано: {total_keys}\n" + f" ✅ Действующих: {active_keys}\n" + f" ❌ Просроченных: {expired_keys}\n\n" + f"💰 Финансовая статистика:\n" + f" 📅 За день: {total_payments_today} ₽\n" + f" 📆 За неделю: {total_payments_week} ₽\n" + f" 📆 За месяц: {total_payments_month} ₽\n" + f" 🏦 За все время: {total_payments_all_time} ₽\n" + ) + + kb = build_stats_kb() + + await callback_query.message.edit_text( + text=stats_message, + reply_markup=kb + ) + except Exception as e: + logger.error(f"Error in user_stats_menu: {e}") + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats_export_users_csv"), + IsAdminFilter(), +) +async def export_users_csv(callback_query: CallbackQuery, session: Any): + kb = build_admin_back_kb("stats") + + try: + users = await session.fetch( + """ + SELECT + u.tg_id, + u.username, + u.first_name, + u.last_name, + u.language_code, + u.is_bot, + c.balance, + c.trial + FROM users u + LEFT JOIN connections c ON u.tg_id = c.tg_id + """ + ) + + if not users: + await callback_query.message.answer( + text="📭 Нет пользователей для экспорта.", + reply_markup=kb + ) + return + + csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n" # Заголовки CSV + for user in users: + csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n" + + file_name = BytesIO(csv_data.encode("utf-8-sig")) + file_name.seek(0) + + file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv") + + await callback_query.message.answer_document( + file, + caption="📥 Экспорт пользователей в CSV", + reply_markup=kb + ) + file_name.close() + + except Exception as e: + logger.error(f"Ошибка при экспорте пользователей в CSV: {e}") + await callback_query.message.answer( + text="❗ Произошла ошибка при экспорте пользователей.", + reply_markup=kb + ) + + +@router.callback_query( + AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), + IsAdminFilter(), +) +async def export_payments_csv(callback_query: CallbackQuery, session: Any): + kb = build_admin_back_kb("stats") + + try: + payments = await session.fetch( + """ + SELECT + u.tg_id, + u.username, + u.first_name, + u.last_name, + p.amount, + p.payment_system, + p.status, + p.created_at + FROM users u + JOIN payments p ON u.tg_id = p.tg_id + """ + ) + + if not payments: + await callback_query.message.answer( + text="📭 Нет платежей для экспорта.", + reply_markup=kb + ) + return + + csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV + for payment in payments: + csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n" + + file_name = BytesIO(csv_data.encode("utf-8-sig")) + file_name.seek(0) + + file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv") + + await callback_query.message.answer_document( + document=file, + caption="📥 Экспорт платежей в CSV", + reply_markup=kb + ) + file_name.close() + + except Exception as e: + logger.error(f"Ошибка при экспорте платежей в CSV: {e}") + await callback_query.message.answer( + text="❗ Произошла ошибка при экспорте платежей.", + reply_markup=kb + ) diff --git a/keyboards/admin/stats_kb.py b/keyboards/admin/stats_kb.py new file mode 100644 index 00000000..3e3b9b04 --- /dev/null +++ b/keyboards/admin/stats_kb.py @@ -0,0 +1,24 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from keyboards.admin.panel_kb import build_admin_back_btn, AdminPanelCallback + + +def build_stats_kb() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button( + text="🔄 Обновить", + callback_data=AdminPanelCallback(action="stats").pack() + ) + builder.button( + text="📥 Выгрузить пользователей в CSV", + callback_data=AdminPanelCallback(action="stats_export_users_csv").pack() + ) + builder.button( + text="📥 Выгрузить оплаты в CSV", + callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack() + ) + builder.row( + build_admin_back_btn("admin") + ) + return builder.as_markup()