From fba217b87f679632ae10090af153671e143f14ed Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 20 Nov 2025 22:49:57 +0300 Subject: [PATCH] Fix trial reset by clearing server links --- app/bot.py | 2 + app/database/crud/subscription.py | 109 +++++++++++++++++++++++++++++- app/handlers/admin/trials.py | 86 +++++++++++++++++++++++ app/keyboards/admin.py | 18 +++++ app/localization/locales/en.json | 6 ++ app/localization/locales/ru.json | 6 ++ app/localization/locales/ua.json | 6 ++ app/localization/locales/zh.json | 6 ++ 8 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 app/handlers/admin/trials.py 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":"等待付款",