diff --git a/app/bot.py b/app/bot.py
index 4d4c9d87..4ecac835 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -59,6 +59,7 @@ from app.handlers.admin import (
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
+ trials as admin_trials,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
+ admin_trials.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index 7e1e92d5..1744175e 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timedelta
from typing import Iterable, Optional, List, Tuple
-from sqlalchemy import select, and_, func
+from sqlalchemy import select, and_, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -676,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month,
- "trial_to_paid_conversion": trial_to_paid_conversion,
- "renewals_count": renewals_count
+ "trial_to_paid_conversion": trial_to_paid_conversion,
+ "renewals_count": renewals_count
}
+
+async def get_trial_statistics(db: AsyncSession) -> dict:
+ now = datetime.utcnow()
+
+ total_trials_result = await db.execute(
+ select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
+ )
+ total_trials = total_trials_result.scalar() or 0
+
+ active_trials_result = await db.execute(
+ select(func.count(Subscription.id)).where(
+ Subscription.is_trial.is_(True),
+ Subscription.end_date > now,
+ Subscription.status.in_(
+ [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
+ ),
+ )
+ )
+ active_trials = active_trials_result.scalar() or 0
+
+ resettable_trials_result = await db.execute(
+ select(func.count(Subscription.id))
+ .join(User, Subscription.user_id == User.id)
+ .where(
+ Subscription.is_trial.is_(True),
+ Subscription.end_date <= now,
+ User.has_had_paid_subscription.is_(False),
+ )
+ )
+ resettable_trials = resettable_trials_result.scalar() or 0
+
+ return {
+ "used_trials": total_trials,
+ "active_trials": active_trials,
+ "resettable_trials": resettable_trials,
+ }
+
+
+async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
+ now = datetime.utcnow()
+
+ result = await db.execute(
+ select(Subscription)
+ .options(
+ selectinload(Subscription.user),
+ selectinload(Subscription.subscription_servers),
+ )
+ .join(User, Subscription.user_id == User.id)
+ .where(
+ Subscription.is_trial.is_(True),
+ Subscription.end_date <= now,
+ User.has_had_paid_subscription.is_(False),
+ )
+ )
+
+ subscriptions = result.scalars().unique().all()
+ if not subscriptions:
+ return 0
+
+ reset_count = len(subscriptions)
+ for subscription in subscriptions:
+ try:
+ await decrement_subscription_server_counts(
+ db,
+ subscription,
+ subscription_servers=subscription.subscription_servers,
+ )
+ except Exception as error: # pragma: no cover - defensive logging
+ logger.error(
+ "Не удалось обновить счётчики серверов при сбросе триала %s: %s",
+ subscription.id,
+ error,
+ )
+
+ subscription_ids = [subscription.id for subscription in subscriptions]
+
+ if subscription_ids:
+ try:
+ await db.execute(
+ delete(SubscriptionServer).where(
+ SubscriptionServer.subscription_id.in_(subscription_ids)
+ )
+ )
+ except Exception as error: # pragma: no cover - defensive logging
+ logger.error(
+ "Ошибка удаления серверных связей триалов %s: %s",
+ subscription_ids,
+ error,
+ )
+ raise
+
+ await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
+
+ try:
+ await db.commit()
+ except Exception as error: # pragma: no cover - defensive logging
+ await db.rollback()
+ logger.error("Ошибка сохранения сброса триалов: %s", error)
+ raise
+
+ logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
+ return reset_count
+
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py
new file mode 100644
index 00000000..9a95f192
--- /dev/null
+++ b/app/handlers/admin/trials.py
@@ -0,0 +1,86 @@
+import logging
+
+from aiogram import Dispatcher, F, types
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database.crud.subscription import (
+ get_trial_statistics,
+ reset_trials_for_users_without_paid_subscription,
+)
+from app.database.models import User
+from app.keyboards.admin import get_admin_trials_keyboard
+from app.localization.texts import get_texts
+from app.utils.decorators import admin_required, error_handler
+
+logger = logging.getLogger(__name__)
+
+
+@admin_required
+@error_handler
+async def show_trials_panel(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ stats = await get_trial_statistics(db)
+ message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
+ "ADMIN_TRIALS_STATS",
+ "• Использовано всего: {used}\n"
+ "• Активно сейчас: {active}\n"
+ "• Доступно к сбросу: {resettable}",
+ ).format(
+ used=stats.get("used_trials", 0),
+ active=stats.get("active_trials", 0),
+ resettable=stats.get("resettable_trials", 0),
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=get_admin_trials_keyboard(db_user.language),
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def reset_trials(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ reset_count = await reset_trials_for_users_without_paid_subscription(db)
+ stats = await get_trial_statistics(db)
+
+ message = texts.t(
+ "ADMIN_TRIALS_RESET_RESULT",
+ "♻️ Сбросили {reset_count} триалов.\n\n"
+ "• Использовано всего: {used}\n"
+ "• Активно сейчас: {active}\n"
+ "• Доступно к сбросу: {resettable}",
+ ).format(
+ reset_count=reset_count,
+ used=stats.get("used_trials", 0),
+ active=stats.get("active_trials", 0),
+ resettable=stats.get("resettable_trials", 0),
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=get_admin_trials_keyboard(db_user.language),
+ )
+ await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
+
+
+def register_handlers(dp: Dispatcher) -> None:
+ dp.callback_query.register(
+ show_trials_panel,
+ F.data == "admin_trials",
+ )
+ dp.callback_query.register(
+ reset_trials,
+ F.data == "admin_trials_reset",
+ )
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index db1abbc9..6272a531 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
),
],
[
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
+ callback_data="admin_trials",
+ ),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
@@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
])
+def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
+ callback_data="admin_trials_reset",
+ )
+ ],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
+ ])
+
+
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index a93a87c4..dc474150 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
+ "ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 Top-up verification",
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
+ "ADMIN_TRIALS_TITLE": "🧪 Trial management",
+ "ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
+ "ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
+ "ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
+ "ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 641630ea..da8b89f9 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
+ "ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 Проверка пополнений",
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
+ "ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
+ "ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
+ "ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
+ "ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
+ "ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 6b9ad510..4c8fb4b9 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -129,6 +129,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Налаштування",
"ADMIN_MAIN_SUPPORT": "🛟 Підтримка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
+ "ADMIN_MAIN_TRIALS": "🧪 Тріали",
"ADMIN_MAIN_PAYMENTS": "💳 Поповнення",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки",
"ADMIN_MESSAGES": "📨 Розсилки",
@@ -167,6 +168,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 Перевірка поповнень",
"ADMIN_PAYMENTS_DESCRIPTION": "Список рахунків на поповнення, створених за останні 24 години, які очікують на оплату.",
"ADMIN_PAYMENTS_NOTICE": "Перевіряти можна лише рахунки, молодші 24 годин, зі статусом очікування.",
+ "ADMIN_TRIALS_TITLE": "🧪 Керування тріалами",
+ "ADMIN_TRIALS_STATS": "• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}",
+ "ADMIN_TRIALS_RESET_BUTTON": "♻️ Скинути всі тріали",
+ "ADMIN_TRIALS_RESET_RESULT": "♻️ Скинули {reset_count} тріалів.\n\n• Використано всього: {used}\n• Активні зараз: {active}\n• Доступні для скидання: {resettable}",
+ "ADMIN_TRIALS_RESET_TOAST": "✅ Скидання завершено",
"ADMIN_PAYMENTS_EMPTY": "За останні 24 години не знайдено рахунків на поповнення в очікуванні.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Очікує оплати",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 4c9f765f..84210ac8 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -129,6 +129,7 @@
"ADMIN_MAIN_SETTINGS":"⚙️设置",
"ADMIN_MAIN_SUPPORT":"🛟支持",
"ADMIN_MAIN_SYSTEM":"🛠️系统",
+"ADMIN_MAIN_TRIALS":"🧪试用",
"ADMIN_MAIN_PAYMENTS":"💳充值",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅",
"ADMIN_MESSAGES":"📨广播",
@@ -167,6 +168,11 @@
"ADMIN_PAYMENTS_TITLE":"💳充值检查",
"ADMIN_PAYMENTS_DESCRIPTION":"过去24小时内创建并等待付款的充值账单列表。",
"ADMIN_PAYMENTS_NOTICE":"只能检查24小时内且状态为等待中的账单。",
+"ADMIN_TRIALS_TITLE":"🧪 试用管理",
+"ADMIN_TRIALS_STATS":"• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}",
+"ADMIN_TRIALS_RESET_BUTTON":"♻️ 重置所有试用",
+"ADMIN_TRIALS_RESET_RESULT":"♻️ 已重置 {reset_count} 个试用。\n\n• 已使用试用总数: {used}\n• 当前活跃: {active}\n• 可重置: {resettable}",
+"ADMIN_TRIALS_RESET_TOAST":"✅ 重置完成",
"ADMIN_PAYMENTS_EMPTY":"过去24小时内未找到等待中的充值账单。",
"ADMIN_PAYMENTS_ITEM_DETAILS":"📄№{number}",
"ADMIN_PAYMENT_STATUS_PENDING":"等待付款",