diff --git a/assets/schema.sql b/assets/schema.sql
index b3de6a4b..9db6f6ae 100644
--- a/assets/schema.sql
+++ b/assets/schema.sql
@@ -72,11 +72,22 @@ CREATE TABLE IF NOT EXISTS coupons
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
+ days INTEGER CHECK (days > 0 OR days IS NULL),
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'coupons' AND column_name = 'days'
+ ) THEN
+ ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
+ END IF;
+END$$;
+
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
diff --git a/database.py b/database.py
index 6b9406d5..ab89a022 100644
--- a/database.py
+++ b/database.py
@@ -124,33 +124,36 @@ async def check_server_name_by_cluster(server_name: str, session: Any) -> dict |
raise
-async def create_coupon(coupon_code: str, amount: float, usage_limit: int, session: Any):
+async def create_coupon(coupon_code: str, amount: int, usage_limit: int, session: Any, days: int = None):
"""
Создает новый купон в базе данных.
Args:
coupon_code (str): Уникальный код купона.
- amount (float): Сумма, которую дает купон.
+ amount (int): Сумма, которую дает купон (0 для купонов на дни).
usage_limit (int): Максимальное количество использований купона.
session (Any): Сессия базы данных для выполнения запроса.
+ days (int, optional): Количество дней для продления подписки.
Raises:
Exception: В случае ошибки при создании купона.
Example:
- await create_coupon('SALE50', 50.0, 5, session)
+ await create_coupon('SALE50', 50, 5, session)
+ await create_coupon('DAYS10', 0, 50, session, days=10)
"""
try:
await session.execute(
"""
- INSERT INTO coupons (code, amount, usage_limit, usage_count, is_used)
- VALUES ($1, $2, $3, 0, FALSE)
- """,
+ INSERT INTO coupons (code, amount, usage_limit, usage_count, is_used, days)
+ VALUES ($1, $2, $3, 0, FALSE, $4)
+ """,
coupon_code,
amount,
usage_limit,
+ days,
)
- logger.info(f"Успешно создан купон с кодом {coupon_code} на сумму {amount}")
+ logger.info(f"Успешно создан купон с кодом {coupon_code} на сумму {amount} или {days} дней")
except Exception as e:
logger.error(f"Ошибка при создании купона {coupon_code}: {e}")
raise
@@ -170,7 +173,8 @@ async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
- usage_limit (int): Лимит использований
- usage_count (int): Текущее количество использований
- is_used (bool): Флаг использования
- - amount (float): Сумма купона
+ - amount (int): Сумма купона
+ - days (int): Количество дней (если есть)
Raises:
Exception: В случае ошибки при выполнении запроса
@@ -178,7 +182,7 @@ async def get_coupon_by_code(coupon_code: str, session: Any) -> dict | None:
try:
result = await session.fetchrow(
"""
- SELECT id, usage_limit, usage_count, is_used, amount
+ SELECT id, usage_limit, usage_count, is_used, amount, days
FROM coupons
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
""",
@@ -213,7 +217,7 @@ async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
offset = (page - 1) * per_page
coupons = await session.fetch(
"""
- SELECT code, amount, usage_limit, usage_count
+ SELECT id, code, amount, usage_limit, usage_count, days, is_used -- Добавлено id
FROM coupons
ORDER BY id
LIMIT $1 OFFSET $2
@@ -221,12 +225,9 @@ async def get_all_coupons(session: Any, page: int = 1, per_page: int = 10):
per_page,
offset,
)
-
total_count = await session.fetchval("SELECT COUNT(*) FROM coupons")
- total_pages = -(-total_count // per_page) # Округление вверх
-
+ total_pages = -(-total_count // per_page)
logger.info(f"Успешно получено {len(coupons)} купонов из базы данных (страница {page})")
-
return {"coupons": coupons, "total": total_count, "pages": total_pages, "current_page": page}
except Exception as e:
logger.error(f"Критическая ошибка при получении списка купонов: {e}")
@@ -1683,12 +1684,12 @@ async def get_last_payments(tg_id: int, session: Any):
raise
-async def get_coupon_details(coupon_id: str, session: Any):
+async def get_coupon_details(coupon_id: int, session: Any):
"""
Получает детали купона по его ID.
Args:
- coupon_id (str): ID купона
+ coupon_id (int): ID купона
session (Any): Сессия базы данных
Returns:
@@ -1700,20 +1701,17 @@ async def get_coupon_details(coupon_id: str, session: Any):
try:
record = await session.fetchrow(
"""
- SELECT id, code, discount, usage_count, usage_limit, is_used
+ SELECT id, code, amount, days, usage_count, usage_limit, is_used
FROM coupons
WHERE id = $1
""",
coupon_id,
)
-
if record:
logger.info(f"Успешно получены детали купона {coupon_id}")
return dict(record)
-
logger.warning(f"Купон {coupon_id} не найден")
return None
-
except Exception as e:
logger.error(f"Ошибка при получении деталей купона {coupon_id}: {e}")
raise
diff --git a/handlers/admin/clusters/clusters_handler.py b/handlers/admin/clusters/clusters_handler.py
index a169d8b2..0132fbbd 100644
--- a/handlers/admin/clusters/clusters_handler.py
+++ b/handlers/admin/clusters/clusters_handler.py
@@ -616,7 +616,7 @@ async def handle_new_cluster_name_input(message: Message, state: FSMContext, ses
)
await message.answer(
- text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
+ text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
@@ -722,10 +722,7 @@ async def handle_new_server_name_input(message: Message, state: FSMContext, sess
old_server_name
)
- # Формируем текст сообщения с учетом USE_COUNTRY_SELECTION
- base_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
- sync_reminder = "\n\n⚠️ Не забудьте сделать \"Синхронизацию\"."
- final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
+ final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
await message.answer(
text=final_text,
diff --git a/handlers/admin/coupons/coupons_handler.py b/handlers/admin/coupons/coupons_handler.py
index 2b5e276e..4dcd8a78 100644
--- a/handlers/admin/coupons/coupons_handler.py
+++ b/handlers/admin/coupons/coupons_handler.py
@@ -1,33 +1,58 @@
+from datetime import datetime
+import html
+import pytz
from typing import Any
from aiogram import F, Router
+from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import CallbackQuery, Message
+from aiogram.types import (
+ CallbackQuery,
+ InlineQuery,
+ InlineQueryResultArticle,
+ InputTextMessageContent,
+ Message,
+)
+from aiogram.utils.keyboard import InlineKeyboardBuilder
-from config import USERNAME_BOT
-from database import create_coupon, delete_coupon, get_all_coupons
+from config import INLINE_MODE, USERNAME_BOT
+from database import (
+ add_connection,
+ check_connection_exists,
+ create_coupon,
+ create_coupon_usage,
+ delete_coupon,
+ get_all_coupons,
+ get_keys,
+ update_key_expiry,
+)
from filters.admin import IsAdminFilter
+from handlers.buttons import BACK
+from handlers.keys.key_utils import renew_key_in_cluster
+from handlers.profile import process_callback_view_profile
+from handlers.utils import format_days
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
-from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb
+from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
router = Router()
class AdminCouponsState(StatesGroup):
- waiting_for_coupon_data = State()
+ waiting_for_coupon_type = State()
+ waiting_for_balance_data = State()
+ waiting_for_days_data = State()
+ waiting_for_key_selection = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
-async def handle_coupons(
- callback_query: CallbackQuery,
-):
+async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@@ -36,25 +61,52 @@ async def handle_coupons(
IsAdminFilter(),
)
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
+ text = "🎫 Выберите тип купона:"
+ kb = InlineKeyboardBuilder()
+ kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
+ kb.button(text="⏳ Время", callback_data="coupon_type_days")
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
+ kb.adjust(1)
+
+ await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
+ await state.set_state(AdminCouponsState.waiting_for_coupon_type)
+
+
+@router.callback_query(F.data == "coupon_type_balance")
+async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 Введите данные для создания купона в формате:\n\n"
"📝 код 💰 сумма 🔢 лимит\n\n"
"Пример: 'COUPON1 50 5' 👈\n\n"
)
+ kb = InlineKeyboardBuilder()
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
- await callback_query.message.edit_text(
- text=text,
- reply_markup=build_admin_back_kb("coupons"),
+ await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
+ await state.set_state(AdminCouponsState.waiting_for_balance_data)
+
+
+@router.callback_query(F.data == "coupon_type_days")
+async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
+ text = (
+ "🎫 Введите данные для создания купона в формате:\n\n"
+ "📝 код ⏳ дни 🔢 лимит\n\n"
+ "Пример: 'DAYS10 10 50' 👈\n\n"
)
- await state.set_state(AdminCouponsState.waiting_for_coupon_data)
+ kb = InlineKeyboardBuilder()
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
+
+ await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
+ await state.set_state(AdminCouponsState.waiting_for_days_data)
-@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
-async def handle_coupon_data_input(message: Message, state: FSMContext, session: Any):
+@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
+async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
- kb = build_admin_back_kb("coupons")
+ kb = InlineKeyboardBuilder()
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
if len(parts) != 3:
text = (
@@ -62,41 +114,96 @@ async def handle_coupon_data_input(message: Message, state: FSMContext, session:
"🏷️ код 💰 сумма 🔢 лимит\n"
"Пример: 'COUPON1 50 5' 👈"
)
-
- await message.answer(
- text=text,
- reply_markup=kb,
- )
+ await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
- coupon_amount = float(parts[1])
+ coupon_amount = int(parts[1])
usage_limit = int(parts[2])
+ if coupon_amount <= 0:
+ raise ValueError("Сумма должна быть больше 0")
except ValueError:
text = "⚠️ Проверьте правильность введенных данных!\n💱 Сумма должна быть числом, а лимит — целым числом."
-
- await message.answer(
- text=text,
- reply_markup=kb,
- )
+ await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
- await create_coupon(coupon_code, coupon_amount, usage_limit, session)
+ await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
+ coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом {coupon_code} успешно создан!\n"
- f"💰 Сумма: {coupon_amount} рублей \n"
+ f"💰 Сумма: {coupon_amount} рублей\n"
f"🔢 Лимит использования: {usage_limit} раз\n"
- f"🔗 Ссылка: https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}\n"
+ f"🔗 Ссылка: {coupon_link}\n"
)
- await message.answer(text=text, reply_markup=kb)
+ kb = InlineKeyboardBuilder()
+ if INLINE_MODE:
+ kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
+ kb.adjust(1)
+
+ await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
+ await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
+
+
+@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
+async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
+ text = message.text.strip()
+ parts = text.split()
+
+ kb = InlineKeyboardBuilder()
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
+
+ if len(parts) != 3:
+ text = (
+ "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n"
+ "🏷️ код ⏳ дни 🔢 лимит\n"
+ "Пример: 'DAYS10 10 50' 👈"
+ )
+ await message.answer(text=text, reply_markup=kb.as_markup())
+ return
+
+ try:
+ coupon_code = parts[0]
+ days = int(parts[1])
+ usage_limit = int(parts[2])
+ if days <= 0:
+ raise ValueError("Количество дней должно быть больше 0")
+ except ValueError:
+ text = "⚠️ Проверьте правильность введенных данных!\n💱 Дни должны быть числом, а лимит — целым числом."
+ await message.answer(text=text, reply_markup=kb.as_markup())
+ return
+
+ try:
+ await create_coupon(coupon_code, 0, usage_limit, session, days=days)
+
+ coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
+ text = (
+ f"✅ Купон с кодом {coupon_code} успешно создан!\n"
+ f"⏳ {format_days(days)}\n"
+ f"🔢 Лимит использования: {usage_limit} раз\n"
+ f"🔗 Ссылка: {coupon_link}\n"
+ )
+
+ kb = InlineKeyboardBuilder()
+ if INLINE_MODE:
+ kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
+ kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
+ kb.adjust(1)
+
+ await message.answer(text=text, reply_markup=kb.as_markup())
+ await state.clear()
+
+ except Exception as e:
+ logger.error(f"Ошибка при создании купона: {e}")
+ await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.callback_query(
@@ -107,46 +214,55 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
- per_page = 10
- result = await get_all_coupons(session, page, per_page)
- coupons = result["coupons"]
+ await update_coupons_list(callback_query.message, session, page)
+ except Exception as e:
+ logger.error(f"Ошибка при получении списка купонов: {e}")
+ await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
- if not coupons:
+
+@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
+async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
+ coupon_code = callback_data.coupon_code
+ kb = InlineKeyboardBuilder()
+ kb.button(
+ text="✅ Да, удалить",
+ callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
+ )
+ kb.button(
+ text="❌ Нет, отменить",
+ callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
+ )
+ kb.adjust(1)
+
+ await callback_query.message.edit_text(
+ f"Вы уверены, что хотите удалить купон {coupon_code}?",
+ reply_markup=kb.as_markup()
+ )
+
+
+@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
+async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
+ coupon_code = callback_data.coupon_code
+ confirm = callback_data.confirm
+
+ if confirm:
+ try:
+ result = await delete_coupon(coupon_code, session)
+ if not result:
+ await callback_query.message.edit_text(
+ f"❌ Купон с кодом {coupon_code} не найден.",
+ reply_markup=build_admin_back_kb("coupons")
+ )
+ return
+ except Exception as e:
+ logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text(
- text="❌ На данный момент нет доступных купонов!",
- reply_markup=build_admin_back_kb("coupons"),
+ "Произошла ошибка при удалении купона.",
+ reply_markup=build_admin_back_kb("coupons")
)
return
- kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
- coupon_list = "📜 Список всех купонов:\n\n"
- for coupon in coupons:
- coupon_list += (
- f"🏷️ Код: {coupon['code']}\n"
- f"💰 Сумма: {coupon['amount']} рублей\n"
- f"🔢 Лимит использования: {coupon['usage_limit']} раз\n"
- f"✅ Использовано: {coupon['usage_count']} раз\n"
- f"🔗 Ссылка: https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}\n"
- )
- await callback_query.message.edit_text(text=coupon_list, reply_markup=kb)
- except Exception as e:
- logger.error(f"Ошибка при получении списка купонов: {e}")
- await callback_query.message.answer("Произошла ошибка при получении списка купонов.")
-
-
-@router.callback_query(AdminCouponDeleteCallback.filter(), IsAdminFilter())
-async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
- coupon_code = callback_data.coupon_code
- try:
- result = await delete_coupon(coupon_code, session)
- if result:
- await callback_query.message.edit_text(f"Купон {coupon_code} удалён!")
- else:
- await callback_query.message.edit_text(f"❌ Купон с кодом {coupon_code} не найден.", show_alert=True)
- await update_coupons_list(callback_query.message, session)
- except Exception as e:
- logger.error(f"Ошибка при удалении купона: {e}")
- await callback_query.message.edit_text("Произошла ошибка при удалении купона.", show_alert=True)
+ await update_coupons_list(callback_query.message, session)
async def update_coupons_list(message, session: Any, page: int = 1):
@@ -162,13 +278,206 @@ async def update_coupons_list(message, session: Any, page: int = 1):
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
- coupon_list = "📜 Список всех купонов:\n\n"
- for coupon in coupons:
- coupon_list += (
- f"🏷️ Код: {coupon['code']}\n"
- f"💰 Сумма: {coupon['amount']} рублей\n"
- f"🔢 Лимит использования: {coupon['usage_limit']} раз\n"
- f"✅ Использовано: {coupon['usage_count']} раз\n"
- f"🔗 Ссылка: https://t.me/{USERNAME_BOT}?start=coupons_{coupon['code']}\n"
+ text = format_coupons_list(coupons, USERNAME_BOT)
+ await message.edit_text(text=text, reply_markup=kb)
+
+
+@router.inline_query(F.query.startswith("coupon_"))
+async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
+ if not INLINE_MODE:
+ return
+
+ coupon_code = inline_query.query.split("coupon_")[1]
+ coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
+
+ coupons = await get_all_coupons(session, page=1, per_page=10)
+ coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
+
+ if not coupon:
+ await inline_query.answer(
+ results=[],
+ switch_pm_text="Купон не найден",
+ switch_pm_parameter="coupons",
+ cache_time=1,
)
- await message.edit_text(text=coupon_list, reply_markup=kb)
+ return
+
+ title = f"Купон {coupon['code']}"
+ description = f"Получи {coupon['amount']} рублей!" if coupon["amount"] > 0 else f"Продли подписку на {format_days(coupon['days'])}!"
+ message_text = (
+ f"🎫 Купон: {coupon['code']}\n"
+ f"{'💰 Бонус: ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ Продление: ' + format_days(coupon['days'])}\n"
+ f"👇 Нажми, чтобы активировать!"
+ )
+
+ builder = InlineKeyboardBuilder()
+ builder.button(text="Активировать купон", url=coupon_link)
+
+ result = InlineQueryResultArticle(
+ id=coupon_code,
+ title=title,
+ description=description,
+ input_message_content=InputTextMessageContent(
+ message_text=message_text,
+ parse_mode=ParseMode.HTML
+ ),
+ reply_markup=builder.as_markup(),
+ )
+
+ await inline_query.answer(
+ results=[result],
+ cache_time=86400,
+ is_personal=True
+ )
+
+@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
+async def handle_coupon_activation(message: Message, state: FSMContext, session: Any, admin: bool = False):
+ coupon_code = message.text.split("coupons_")[1]
+
+ coupons = await get_all_coupons(session, page=1, per_page=10)
+ coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
+
+ if not coupon:
+ await message.answer("❌ Купон не найден.")
+ return
+
+ if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
+ await message.answer("❌ Лимит активаций купона исчерпан.")
+ return
+
+ usage = await session.fetchrow(
+ "SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
+ coupon["id"],
+ message.from_user.id
+ )
+ if usage:
+ await message.answer("❌ Вы уже активировали этот купон.")
+ return
+
+ connection_exists = await check_connection_exists(message.from_user.id)
+ if not connection_exists:
+ await add_connection(tg_id=message.from_user.id, session=session)
+
+ if coupon["amount"] > 0:
+ await session.execute(
+ "UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
+ coupon["amount"],
+ message.from_user.id
+ )
+ await session.execute(
+ "UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
+ coupon["usage_count"] + 1 >= coupon["usage_limit"],
+ coupon["id"]
+ )
+ await create_coupon_usage(coupon["id"], message.from_user.id, session)
+ await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
+ await process_callback_view_profile(message, state, admin)
+ return
+
+ if coupon["days"] is not None and coupon["days"] > 0:
+ keys = await get_keys(message.from_user.id, session)
+ active_keys = [k for k in keys if not k["is_frozen"]]
+
+ if not active_keys:
+ await message.answer("❌ У вас нет активных подписок для продления.")
+ return
+
+ builder = InlineKeyboardBuilder()
+ moscow_tz = pytz.timezone("Europe/Moscow")
+ response_message = "🔑 Выберите подписку для продления:\n\n
" + + for key in active_keys: + alias = key.get("alias") + email = key["email"] + client_id = key["client_id"] + expiry_time = key.get("expiry_time") + + key_display = html.escape(alias.strip() if alias else email) + expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M") + response_message += f"• {key_display} ({expiry_date})\n" + builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}") + + response_message += "" + builder.button(text="Отмена", callback_data="cancel_coupon_activation") + builder.adjust(1) + + await message.answer(response_message, reply_markup=builder.as_markup()) + await state.set_state(AdminCouponsState.waiting_for_key_selection) + await state.update_data(coupon_id=coupon["id"]) + return + + await message.answer("❌ Купон недействителен (нет суммы или дней).") + + +@router.callback_query(F.data.startswith("extend_key|")) +async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False): + parts = callback_query.data.split("|") + client_id = parts[1] + coupon_id = int(parts[2]) + + coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id) + if not coupon or coupon["usage_count"] >= coupon["usage_limit"]: + await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.") + await state.clear() + return + + usage = await session.fetchrow( + "SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2", + coupon_id, + callback_query.from_user.id + ) + if usage: + await callback_query.message.edit_text("❌ Вы уже активировали этот купон.") + await state.clear() + return + + key = await session.fetchrow( + "SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2", + callback_query.from_user.id, + client_id + ) + if not key or key["is_frozen"]: + await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.") + await state.clear() + return + + now_ms = int(datetime.now().timestamp() * 1000) + current_expiry = key["expiry_time"] + new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000) + + try: + await renew_key_in_cluster( + cluster_id=key["server_id"], + email=key["email"], + client_id=client_id, + new_expiry_time=new_expiry, + total_gb=0 + ) + await update_key_expiry(client_id, new_expiry, session) + + await session.execute( + "UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2", + coupon["usage_count"] + 1 >= coupon["usage_limit"], + coupon["id"] + ) + await create_coupon_usage(coupon["id"], callback_query.from_user.id, session) + + alias = key.get("alias") or key["email"] + expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M") + text = f"✅ Купон активирован, подписка {alias} продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆." + + await callback_query.message.answer(text) + await process_callback_view_profile(callback_query.message, state, admin) + await state.clear() + + except Exception as e: + logger.error(f"Ошибка при продлении ключа: {e}") + await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.") + await state.clear() + + +@router.callback_query(F.data == "cancel_coupon_activation") +async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False): + await callback_query.message.answer("⚠️ Активация купона отменена.") + await process_callback_view_profile(callback_query.message, state, admin) + await state.clear() diff --git a/handlers/admin/coupons/keyboard.py b/handlers/admin/coupons/keyboard.py index d0ddd9c5..f497ec8e 100644 --- a/handlers/admin/coupons/keyboard.py +++ b/handlers/admin/coupons/keyboard.py @@ -1,14 +1,18 @@ +from typing import Optional + from aiogram.filters.callback_data import CallbackData from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder from handlers.buttons import BACK +from handlers.utils import format_days from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"): coupon_code: str + confirm: Optional[bool] = None def build_coupons_kb() -> InlineKeyboardMarkup: @@ -50,3 +54,17 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> builder.row(build_admin_back_btn("coupons")) builder.adjust(2) return builder.as_markup() + + +def format_coupons_list(coupons: list, username_bot: str) -> str: + coupon_list = "📜 Список всех купонов:\n\n" + for coupon in coupons: + value_text = f"💰 Сумма: {coupon['amount']} рублей" if coupon["amount"] > 0 else f"⏳ {format_days(coupon['days'])}" + coupon_list += ( + f"🏷️ Код: {coupon['code']}\n" + f"{value_text}\n" + f"🔢 Лимит использования: {coupon['usage_limit']} раз\n" + f"✅ Использовано: {coupon['usage_count']} раз\n" + f"🔗 Ссылка:
https://t.me/{username_bot}?start=coupons_{coupon['code']}\n\n"
+ )
+ return coupon_list
diff --git a/handlers/admin/users/users_handler.py b/handlers/admin/users/users_handler.py
index 87c1a9ae..2863496c 100644
--- a/handlers/admin/users/users_handler.py
+++ b/handlers/admin/users/users_handler.py
@@ -612,14 +612,19 @@ async def process_user_search(
balance = int(balance)
+ user_data = await session.fetchrow("SELECT username, created_at, updated_at FROM users WHERE tg_id = $1", tg_id)
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
+ created_at = user_data["created_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
+ updated_at = user_data["updated_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
text = (
f"📊 Информация о пользователе"
f"\n\n🆔 ID: {tg_id}"
f"\n📄 Логин: @{username}"
+ f"\n📅 Дата регистрации: {created_at}"
+ f"\n🏃 Дата активности: {updated_at}"
f"\n💰 Баланс: {balance}"
f"\n👥 Количество рефералов: {referral_count}"
)
diff --git a/handlers/profile.py b/handlers/profile.py
index 8fe644b0..37014528 100644
--- a/handlers/profile.py
+++ b/handlers/profile.py
@@ -113,9 +113,12 @@ async def process_callback_view_profile(
builder = InlineKeyboardBuilder()
if trial_status == 0 or key_count == 0:
- builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
- else:
+ if key_count > 0:
builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"))
+ elif trial_status == 0:
+ builder.row(InlineKeyboardButton(text="🎁 Пробная подписка", callback_data="create_key"))
+ else:
+ builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance"))
row_buttons = []
diff --git a/handlers/start.py b/handlers/start.py
index c68c2f7c..e355bd55 100644
--- a/handlers/start.py
+++ b/handlers/start.py
@@ -31,6 +31,7 @@ from database import (
get_trial,
update_balance,
)
+from handlers.admin.coupons.coupons_handler import handle_coupon_activation
from handlers.buttons import ABOUT_VPN, BACK, CHANNEL, MAIN_MENU, SUPPORT
from handlers.captcha import generate_captcha
from handlers.keys.key_management import create_key
@@ -119,47 +120,8 @@ async def process_start_logic(
try:
if "coupons_" in text:
logger.info(f"Обнаружена ссылка на купон: {text}")
- coupon_code = text.split("coupons_")[1].strip()
-
- coupon = await session.fetchrow(
- "SELECT id, code, amount, usage_limit, usage_count, is_used FROM coupons WHERE code = $1",
- coupon_code,
- )
- if not coupon:
- await message.answer("❌ Купон не найден!")
- return await process_callback_view_profile(message, state, admin)
-
- usage_exists = await session.fetchval(
- "SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
- coupon["id"],
- message.chat.id,
- )
- if usage_exists:
- await message.answer("❌ Вы уже использовали этот купон!")
- return await process_callback_view_profile(message, state, admin)
-
- if coupon["is_used"] or coupon["usage_count"] >= coupon["usage_limit"]:
- await message.answer("❌ Этот купон уже использован!")
- return await process_callback_view_profile(message, state, admin)
-
- connection_exists = await check_connection_exists(message.chat.id)
- if not connection_exists:
- await add_connection(tg_id=message.chat.id, session=session)
-
- await update_balance(message.chat.id, coupon["amount"])
- await session.execute(
- "UPDATE coupons SET usage_count = $1, is_used = $2 WHERE code = $3",
- coupon["usage_count"] + 1,
- coupon["usage_count"] + 1 >= coupon["usage_limit"],
- coupon_code,
- )
- await session.execute(
- "INSERT INTO coupon_usages (coupon_id, user_id, used_at) VALUES ($1, $2, NOW())",
- coupon["id"],
- message.chat.id,
- )
- await message.answer(COUPON_SUCCESS_MSG.format(amount=coupon["amount"]))
- return await process_callback_view_profile(message, state, admin)
+ await handle_coupon_activation(message, state, session)
+ return
if "gift_" in text:
parts = text.split("gift_")[1].split("_")
diff --git a/handlers/utils.py b/handlers/utils.py
index 75171ac4..72b939ef 100644
--- a/handlers/utils.py
+++ b/handlers/utils.py
@@ -152,6 +152,27 @@ def format_time_until_deletion(seconds: int) -> str:
return " и ".join(parts) if parts else "менее минуты"
+def get_plural_form(num: int, form1: str, form2: str, form3: str) -> str:
+ n = abs(num) % 100
+ if 10 < n < 20:
+ return form3
+ return {1: form1, 2: form2, 3: form2, 4: form2}.get(n % 10, form3)
+
+def format_days(days: int) -> str:
+ """
+ Форматирует количество дней с правильным склонением.
+
+ Args:
+ days (int): Количество дней.
+
+ Returns:
+ str: Строка с числом и склонённым словом "день/дня/дней".
+ """
+ if days <= 0:
+ return "0 дней"
+ return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
+
+
async def edit_or_send_message(
target_message: Message,
text: str,