diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index f7032f40..a7d23289 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates -from app.database.models import User, PromoCodeType +from app.database.models import User, PromoCodeType, PromoCode from app.keyboards.admin import ( get_admin_promocodes_keyboard, get_promocode_type_keyboard, get_admin_pagination_keyboard, get_confirmation_keyboard @@ -183,6 +183,590 @@ async def show_promocode_management( await callback.answer() +@admin_required +@error_handler +async def show_promocode_edit_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + promo_id = int(callback.data.split('_')[-1]) + + promo = await db.get(PromoCode, promo_id) + if not promo: + await callback.answer("❌ Промокод не найден", show_alert=True) + return + + text = f""" +✏️ Редактирование промокода {promo.code} + +Выберите параметр для изменения: +""" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="📅 Дата окончания", + callback_data=f"promo_edit_date_{promo.id}" + ) + ], + [ + types.InlineKeyboardButton( + text="📊 Количество использований", + callback_data=f"promo_edit_uses_{promo.id}" + ) + ] + ] + + if promo.type == PromoCodeType.BALANCE.value: + keyboard.insert(1, [ + types.InlineKeyboardButton( + text="💰 Сумма бонуса", + callback_data=f"promo_edit_amount_{promo.id}" + ) + ]) + elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]: + keyboard.insert(1, [ + types.InlineKeyboardButton( + text="📅 Количество дней", + callback_data=f"promo_edit_days_{promo.id}" + ) + ]) + + keyboard.extend([ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"promo_manage_{promo.id}" + ) + ] + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_promocode_date( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_id = int(callback.data.split('_')[-1]) + + await state.update_data( + editing_promo_id=promo_id, + edit_action="date" + ) + + text = """ +📅 Изменение даты окончания промокода + +Введите количество дней до окончания (от текущего момента): +• Введите 0 для бессрочного промокода +• Введите положительное число для установки срока + +Например: 30 (промокод будет действовать 30 дней) +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await state.set_state(AdminStates.setting_promocode_expiry) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_promocode_amount( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_id = int(callback.data.split('_')[-1]) + + await state.update_data( + editing_promo_id=promo_id, + edit_action="amount" + ) + + text = """ +💰 Изменение суммы бонуса промокода + +Введите новую сумму в рублях: +Например: 500 +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await state.set_state(AdminStates.setting_promocode_value) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_promocode_days( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_id = int(callback.data.split('_')[-1]) + + await state.update_data( + editing_promo_id=promo_id, + edit_action="days" + ) + + text = """ +📅 Изменение количества дней подписки + +Введите новое количество дней: +Например: 30 +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await state.set_state(AdminStates.setting_promocode_value) + await callback.answer() + + +@admin_required +@error_handler +async def start_edit_promocode_uses( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_id = int(callback.data.split('_')[-1]) + + await state.update_data( + editing_promo_id=promo_id, + edit_action="uses" + ) + + text = """ +📊 Изменение максимального количества использований + +Введите новое количество использований: +• Введите 0 для безлимитных использований +• Введите положительное число для ограничения + +Например: 100 +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await state.set_state(AdminStates.setting_promocode_uses) + await callback.answer() + + +@admin_required +@error_handler +async def start_promocode_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + await callback.message.edit_text( + "🎫 Создание промокода\n\n" + "Выберите тип промокода:", + reply_markup=get_promocode_type_keyboard(db_user.language) + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_promocode_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + promo_type = callback.data.split('_')[-1] + + type_names = { + "balance": "💰 Пополнение баланса", + "days": "📅 Дни подписки", + "trial": "🎁 Тестовая подписка" + } + + await state.update_data(promocode_type=promo_type) + + await callback.message.edit_text( + f"🎫 Создание промокода\n\n" + f"Тип: {type_names.get(promo_type, promo_type)}\n\n" + f"Введите код промокода (только латинские буквы и цифры):", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")] + ]) + ) + + await state.set_state(AdminStates.creating_promocode) + await callback.answer() + + +@admin_required +@error_handler +async def process_promocode_code( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + code = message.text.strip().upper() + + if not code.isalnum() or len(code) < 3 or len(code) > 20: + await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)") + return + + existing = await get_promocode_by_code(db, code) + if existing: + await message.answer("❌ Промокод с таким кодом уже существует") + return + + await state.update_data(promocode_code=code) + + data = await state.get_data() + promo_type = data.get('promocode_type') + + if promo_type == "balance": + await message.answer( + f"💰 Промокод: {code}\n\n" + f"Введите сумму пополнения баланса (в рублях):" + ) + await state.set_state(AdminStates.setting_promocode_value) + elif promo_type == "days": + await message.answer( + f"📅 Промокод: {code}\n\n" + f"Введите количество дней подписки:" + ) + await state.set_state(AdminStates.setting_promocode_value) + elif promo_type == "trial": + await message.answer( + f"🎁 Промокод: {code}\n\n" + f"Введите количество дней тестовой подписки:" + ) + await state.set_state(AdminStates.setting_promocode_value) + + +@admin_required +@error_handler +async def process_promocode_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + + if data.get('editing_promo_id'): + await handle_edit_value(message, db_user, state, db) + return + + try: + value = int(message.text.strip()) + + promo_type = data.get('promocode_type') + + if promo_type == "balance" and (value < 1 or value > 10000): + await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") + return + elif promo_type in ["days", "trial"] and (value < 1 or value > 3650): + await message.answer("❌ Количество дней должно быть от 1 до 3650") + return + + await state.update_data(promocode_value=value) + + await message.answer( + f"📊 Введите количество использований промокода (или 0 для безлимита):" + ) + await state.set_state(AdminStates.setting_promocode_uses) + + except ValueError: + await message.answer("❌ Введите корректное число") + + +async def handle_edit_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + promo_id = data.get('editing_promo_id') + edit_action = data.get('edit_action') + + promo = await db.get(PromoCode, promo_id) + if not promo: + await message.answer("❌ Промокод не найден") + await state.clear() + return + + try: + value = int(message.text.strip()) + + if edit_action == "amount": + if value < 1 or value > 10000: + await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") + return + + await update_promocode(db, promo, balance_bonus_kopeks=value * 100) + await message.answer( + f"✅ Сумма бонуса изменена на {value}₽", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] + ]) + ) + + elif edit_action == "days": + if value < 1 or value > 3650: + await message.answer("❌ Количество дней должно быть от 1 до 3650") + return + + await update_promocode(db, promo, subscription_days=value) + await message.answer( + f"✅ Количество дней изменено на {value}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] + ]) + ) + + await state.clear() + logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}") + + except ValueError: + await message.answer("❌ Введите корректное число") + + +@admin_required +@error_handler +async def process_promocode_uses( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + + if data.get('editing_promo_id'): + await handle_edit_uses(message, db_user, state, db) + return + + try: + max_uses = int(message.text.strip()) + + if max_uses < 0 or max_uses > 100000: + await message.answer("❌ Количество использований должно быть от 0 до 100,000") + return + + if max_uses == 0: + max_uses = 999999 + + await state.update_data(promocode_max_uses=max_uses) + + await message.answer( + f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):" + ) + await state.set_state(AdminStates.setting_promocode_expiry) + + except ValueError: + await message.answer("❌ Введите корректное число") + + +async def handle_edit_uses( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + promo_id = data.get('editing_promo_id') + + promo = await db.get(PromoCode, promo_id) + if not promo: + await message.answer("❌ Промокод не найден") + await state.clear() + return + + try: + max_uses = int(message.text.strip()) + + if max_uses < 0 or max_uses > 100000: + await message.answer("❌ Количество использований должно быть от 0 до 100,000") + return + + if max_uses == 0: + max_uses = 999999 + + if max_uses < promo.current_uses: + await message.answer( + f"❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})" + ) + return + + await update_promocode(db, promo, max_uses=max_uses) + + uses_text = "безлимитное" if max_uses == 999999 else str(max_uses) + await message.answer( + f"✅ Максимальное количество использований изменено на {uses_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] + ]) + ) + + await state.clear() + logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}") + + except ValueError: + await message.answer("❌ Введите корректное число") + + +@admin_required +@error_handler +async def process_promocode_expiry( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + + if data.get('editing_promo_id'): + await handle_edit_expiry(message, db_user, state, db) + return + + try: + expiry_days = int(message.text.strip()) + + if expiry_days < 0 or expiry_days > 3650: + await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") + return + + code = data.get('promocode_code') + promo_type = data.get('promocode_type') + value = data.get('promocode_value', 0) + max_uses = data.get('promocode_max_uses', 1) + + valid_until = None + if expiry_days > 0: + valid_until = datetime.utcnow() + timedelta(days=expiry_days) + + type_map = { + "balance": PromoCodeType.BALANCE, + "days": PromoCodeType.SUBSCRIPTION_DAYS, + "trial": PromoCodeType.TRIAL_SUBSCRIPTION + } + + promocode = await create_promocode( + db=db, + code=code, + type=type_map[promo_type], + balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0, + subscription_days=value if promo_type in ["days", "trial"] else 0, + max_uses=max_uses, + valid_until=valid_until, + created_by=db_user.id + ) + + type_names = { + "balance": "Пополнение баланса", + "days": "Дни подписки", + "trial": "Тестовая подписка" + } + + summary_text = f""" +✅ Промокод создан! + +🎫 Код: {promocode.code} +📝 Тип: {type_names.get(promo_type)} +""" + + if promo_type == "balance": + summary_text += f"💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n" + elif promo_type in ["days", "trial"]: + summary_text += f"📅 Дней: {promocode.subscription_days}\n" + + summary_text += f"📊 Использований: {promocode.max_uses}\n" + + if promocode.valid_until: + summary_text += f"⏰ Действует до: {format_datetime(promocode.valid_until)}\n" + + await message.answer( + summary_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")] + ]) + ) + + await state.clear() + logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}") + + except ValueError: + await message.answer("❌ Введите корректное число дней") + + +async def handle_edit_expiry( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + data = await state.get_data() + promo_id = data.get('editing_promo_id') + + promo = await db.get(PromoCode, promo_id) + if not promo: + await message.answer("❌ Промокод не найден") + await state.clear() + return + + try: + expiry_days = int(message.text.strip()) + + if expiry_days < 0 or expiry_days > 3650: + await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") + return + + valid_until = None + if expiry_days > 0: + valid_until = datetime.utcnow() + timedelta(days=expiry_days) + + await update_promocode(db, promo, valid_until=valid_until) + + if valid_until: + expiry_text = f"до {format_datetime(valid_until)}" + else: + expiry_text = "бессрочно" + + await message.answer( + f"✅ Срок действия промокода изменен: {expiry_text}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] + ]) + ) + + await state.clear() + logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней") + + except ValueError: + await message.answer("❌ Введите корректное число дней") + + @admin_required @error_handler async def toggle_promocode_status( @@ -316,246 +900,21 @@ async def show_promocode_stats( await callback.answer() -@admin_required -@error_handler -async def start_promocode_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - await callback.message.edit_text( - "🎫 Создание промокода\n\n" - "Выберите тип промокода:", - reply_markup=get_promocode_type_keyboard(db_user.language) - ) - await callback.answer() - - -@admin_required -@error_handler -async def select_promocode_type( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - promo_type = callback.data.split('_')[-1] - - type_names = { - "balance": "💰 Пополнение баланса", - "days": "📅 Дни подписки", - "trial": "🎁 Тестовая подписка" - } - - await state.update_data(promocode_type=promo_type) - - await callback.message.edit_text( - f"🎫 Создание промокода\n\n" - f"Тип: {type_names.get(promo_type, promo_type)}\n\n" - f"Введите код промокода (только латинские буквы и цифры):", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")] - ]) - ) - - await state.set_state(AdminStates.creating_promocode) - await callback.answer() - - -@admin_required -@error_handler -async def process_promocode_code( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): - code = message.text.strip().upper() - - if not code.isalnum() or len(code) < 3 or len(code) > 20: - await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)") - return - - from app.database.crud.promocode import get_promocode_by_code - existing = await get_promocode_by_code(db, code) - if existing: - await message.answer("❌ Промокод с таким кодом уже существует") - return - - await state.update_data(promocode_code=code) - - data = await state.get_data() - promo_type = data.get('promocode_type') - - if promo_type == "balance": - await message.answer( - f"💰 Промокод: {code}\n\n" - f"Введите сумму пополнения баланса (в рублях):" - ) - await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "days": - await message.answer( - f"📅 Промокод: {code}\n\n" - f"Введите количество дней подписки:" - ) - await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "trial": - await message.answer( - f"🎁 Промокод: {code}\n\n" - f"Введите количество дней тестовой подписки:" - ) - await state.set_state(AdminStates.setting_promocode_value) - - -@admin_required -@error_handler -async def process_promocode_value( - message: types.Message, - db_user: User, - state: FSMContext -): - try: - value = int(message.text.strip()) - - data = await state.get_data() - promo_type = data.get('promocode_type') - - if promo_type == "balance" and (value < 1 or value > 10000): - await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") - return - elif promo_type in ["days", "trial"] and (value < 1 or value > 3650): - await message.answer("❌ Количество дней должно быть от 1 до 3650") - return - - await state.update_data(promocode_value=value) - - await message.answer( - f"📊 Введите количество использований промокода (или 0 для безлимита):" - ) - await state.set_state(AdminStates.setting_promocode_uses) - - except ValueError: - await message.answer("❌ Введите корректное число") - - -@admin_required -@error_handler -async def process_promocode_uses( - message: types.Message, - db_user: User, - state: FSMContext -): - try: - max_uses = int(message.text.strip()) - - if max_uses < 0 or max_uses > 100000: - await message.answer("❌ Количество использований должно быть от 0 до 100,000") - return - - if max_uses == 0: - max_uses = 999999 - - await state.update_data(promocode_max_uses=max_uses) - - await message.answer( - f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):" - ) - await state.set_state(AdminStates.setting_promocode_expiry) - - except ValueError: - await message.answer("❌ Введите корректное число") - - -@admin_required -@error_handler -async def process_promocode_expiry( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): - try: - expiry_days = int(message.text.strip()) - - if expiry_days < 0 or expiry_days > 3650: - await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") - return - - data = await state.get_data() - code = data.get('promocode_code') - promo_type = data.get('promocode_type') - value = data.get('promocode_value', 0) - max_uses = data.get('promocode_max_uses', 1) - - valid_until = None - if expiry_days > 0: - valid_until = datetime.utcnow() + timedelta(days=expiry_days) - - type_map = { - "balance": PromoCodeType.BALANCE, - "days": PromoCodeType.SUBSCRIPTION_DAYS, - "trial": PromoCodeType.TRIAL_SUBSCRIPTION - } - - promocode = await create_promocode( - db=db, - code=code, - type=type_map[promo_type], - balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0, - subscription_days=value if promo_type in ["days", "trial"] else 0, - max_uses=max_uses, - valid_until=valid_until, - created_by=db_user.id - ) - - type_names = { - "balance": "Пополнение баланса", - "days": "Дни подписки", - "trial": "Тестовая подписка" - } - - summary_text = f""" -✅ Промокод создан! - -🎫 Код: {promocode.code} -📝 Тип: {type_names.get(promo_type)} -""" - - if promo_type == "balance": - summary_text += f"💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n" - elif promo_type in ["days", "trial"]: - summary_text += f"📅 Дней: {promocode.subscription_days}\n" - - summary_text += f"📊 Использований: {promocode.max_uses}\n" - - if promocode.valid_until: - summary_text += f"⏰ Действует до: {format_datetime(promocode.valid_until)}\n" - - await message.answer( - summary_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")] - ]) - ) - - await state.clear() - logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}") - - except ValueError: - await message.answer("❌ Введите корректное число дней") - - def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes") dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list") dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create") dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_")) - dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_")) dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_")) dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_")) dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_")) dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_")) - + dp.callback_query.register(show_promocode_edit_menu, F.data.startswith("promo_edit_")) + dp.callback_query.register(start_edit_promocode_date, F.data.startswith("promo_edit_date_")) + dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_")) + dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_")) + dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_")) dp.message.register(process_promocode_code, AdminStates.creating_promocode) dp.message.register(process_promocode_value, AdminStates.setting_promocode_value) dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)