From 5dd586e0b2cea9d9986629da2039e3bb2c9fa503 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Thu, 11 Dec 2025 22:42:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=20=D0=93=D0=BE=D1=82=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=20=D0=BA=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 1 + app/handlers/admin/users.py | 159 +++++++++++++++++++++++++++++++ app/keyboards/admin.py | 6 ++ app/localization/locales/en.json | 4 + app/localization/locales/ru.json | 4 + app/localization/locales/ua.json | 10 +- app/localization/locales/zh.json | 6 +- app/services/user_service.py | 55 +++++++++++ app/states.py | 1 + 9 files changed, 242 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 00321dd6..d33fd847 100644 --- a/app/config.py +++ b/app/config.py @@ -172,6 +172,7 @@ class Settings(BaseSettings): DEFAULT_AUTOPAY_ENABLED: bool = False DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 + SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000 MONITORING_INTERVAL: int = 60 INACTIVE_USER_DELETE_MONTHS: int = 3 diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 07a604fe..5c145416 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -299,6 +299,137 @@ async def show_users_list_by_balance( await callback.answer() +@admin_required +@error_handler +async def show_users_ready_to_renew( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + """Показывает пользователей с истекшей подпиской и балансом >= порога.""" + await state.set_state(AdminStates.viewing_user_from_ready_to_renew_list) + + texts = get_texts(db_user.language) + threshold = getattr( + settings, + "SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS", + 20000, + ) + + user_service = UserService() + users_data = await user_service.get_users_ready_to_renew( + db, + min_balance_kopeks=threshold, + page=page, + limit=10, + ) + + amount_text = settings.format_price(threshold) + header = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_TITLE", + "♻️ Пользователи готовы к продлению", + ) + description = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_DESC", + "Подписка истекла, а на балансе осталось {amount} или больше.", + ).format(amount=amount_text) + + if not users_data["users"]: + empty_text = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY", + "Сейчас нет пользователей, которые подходят под этот фильтр.", + ) + await callback.message.edit_text( + f"{header}\n\n{description}\n\n{empty_text}", + reply_markup=get_admin_users_keyboard(db_user.language), + ) + await callback.answer() + return + + text = f"{header}\n\n{description}\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + current_time = datetime.utcnow() + + for user in users_data["users"]: + subscription = user.subscription + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" + subscription_emoji = "❌" + expired_days = "?" + + if subscription: + if subscription.is_trial: + subscription_emoji = "🎁" + elif subscription.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + + if subscription.end_date: + delta = current_time - subscription.end_date + expired_days = delta.days + + button_text = ( + f"{status_emoji} {subscription_emoji} {user.full_name}" + f" | 💰 {settings.format_price(user.balance_kopeks)}" + f" | ⏰ {expired_days}д ист." + ) + + if len(button_text) > 60: + short_name = user.full_name + if len(short_name) > 20: + short_name = short_name[:17] + "..." + button_text = ( + f"{status_emoji} {subscription_emoji} {short_name}" + f" | 💰 {settings.format_price(user.balance_kopeks)}" + ) + + keyboard.append([ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_user_manage_{user.id}", + ) + ]) + + if users_data["total_pages"] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data["current_page"], + users_data["total_pages"], + "admin_users_ready_to_renew_list", + "admin_users_ready_to_renew_filter", + db_user.language, + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton( + text="🔍 Поиск", + callback_data="admin_users_search", + ), + types.InlineKeyboardButton( + text="📊 Статистика", + callback_data="admin_users_stats", + ), + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_users", + ) + ], + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + @admin_required @error_handler async def show_users_list_by_traffic( @@ -859,6 +990,22 @@ async def handle_users_purchases_list_pagination( await show_users_list_by_purchases(callback, db_user, db, state, 1) +@admin_required +@error_handler +async def handle_users_ready_to_renew_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + try: + page = int(callback.data.split('_')[-1]) + await show_users_ready_to_renew(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_ready_to_renew(callback, db_user, db, state, 1) + + @admin_required @error_handler async def handle_users_campaign_list_pagination( @@ -1502,6 +1649,8 @@ async def show_user_management( back_callback = "admin_users_purchases_filter" elif current_state == AdminStates.viewing_user_from_campaign_list: back_callback = "admin_users_campaign_filter" + elif current_state == AdminStates.viewing_user_from_ready_to_renew_list: + back_callback = "admin_users_ready_to_renew_filter" # Базовая клавиатура профиля kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) @@ -4860,6 +5009,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_users_purchases_list_page_") ) + dp.callback_query.register( + handle_users_ready_to_renew_pagination, + F.data.startswith("admin_users_ready_to_renew_list_page_") + ) + dp.callback_query.register( handle_users_campaign_list_pagination, F.data.startswith("admin_users_campaign_list_page_") @@ -5131,6 +5285,11 @@ def register_handlers(dp: Dispatcher): show_users_list_by_purchases, F.data == "admin_users_purchases_filter" ) + + dp.callback_query.register( + show_users_ready_to_renew, + F.data == "admin_users_ready_to_renew_filter" + ) dp.callback_query.register( show_users_list_by_campaign, diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 9a08d6d3..c19136c9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -372,6 +372,12 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark callback_data="admin_users_purchases_filter" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"), + callback_data="admin_users_ready_to_renew_filter" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 6ec6a478..bed45d8c 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -711,6 +711,10 @@ "ADMIN_USERS_FILTERS": "⚙️ Filters", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity", "ADMIN_USERS_FILTER_BALANCE": "💰 By balance", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Ready to renew", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Users ready to renew", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Their subscription expired and the balance still has {amount} or more.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "No users match this filter right now.", "ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign", "ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases", "ADMIN_USERS_FILTER_SPENDING": "💳 By spending", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 23304180..ebbc345d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -712,6 +712,10 @@ "ADMIN_USERS_FILTERS": "⚙️ Фильтры", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности", "ADMIN_USERS_FILTER_BALANCE": "💰 По балансу", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готовы к продлению", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.", "ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании", "ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок", "ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 2a419cc2..c70a5f1d 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -710,8 +710,12 @@ "ADMIN_USERS_ALL": "👥 Всі користувачі", "ADMIN_USERS_FILTERS": "⚙️ Фільтри", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 За активністю", - "ADMIN_USERS_FILTER_BALANCE": "💰 За балансом", - "ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією", + "ADMIN_USERS_FILTER_BALANCE": "💰 За балансом", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готові до продовження", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Користувачі, готові до продовження", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Підписка вже закінчилась, а на балансі залишилось {amount} або більше.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Наразі немає користувачів, які підходять під цей фільтр.", + "ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією", "ADMIN_USERS_FILTER_PURCHASES": "🛒 За кількістю покупок", "ADMIN_USERS_FILTER_SPENDING": "💳 За сумою витрат", "ADMIN_USERS_FILTER_TRAFFIC": "📶 За трафіком", @@ -1532,4 +1536,4 @@ "POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.", "POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!", "POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс." -} \ No newline at end of file +} diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 235a9b97..5d3a798e 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -710,6 +710,10 @@ "ADMIN_USERS_FILTERS":"⚙️筛选器", "ADMIN_USERS_FILTER_ACTIVITY":"🕒按活跃度", "ADMIN_USERS_FILTER_BALANCE":"💰按余额", +"ADMIN_USERS_FILTER_RENEW_READY":"♻️准备续费", +"ADMIN_USERS_FILTER_RENEW_READY_TITLE":"♻️准备续费的用户", +"ADMIN_USERS_FILTER_RENEW_READY_DESC":"订阅已到期,但余额仍不少于{amount}。", +"ADMIN_USERS_FILTER_RENEW_READY_EMPTY":"目前没有符合条件的用户。", "ADMIN_USERS_FILTER_CAMPAIGN":"📢按活动", "ADMIN_USERS_FILTER_PURCHASES":"🛒按购买次数", "ADMIN_USERS_FILTER_SPENDING":"💳按消费金额", @@ -1860,4 +1864,4 @@ "POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。", "DEVICE_GUIDE_WINDOWS":"💻Windows", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前" -} \ No newline at end of file +} diff --git a/app/services/user_service.py b/app/services/user_service.py index a0866100..0f723631 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Optional, List, Dict, Any, Tuple from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete, select, update, func +from sqlalchemy.orm import selectinload from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from app.database.crud.user import ( @@ -218,6 +219,60 @@ class UserService: "has_prev": False } + async def get_users_ready_to_renew( + self, + db: AsyncSession, + min_balance_kopeks: int, + page: int = 1, + limit: int = 20, + ) -> Dict[str, Any]: + """Возвращает пользователей с истекшей подпиской и достаточным балансом.""" + try: + offset = (page - 1) * limit + now = datetime.utcnow() + + base_filters = [ + User.balance_kopeks >= min_balance_kopeks, + Subscription.end_date.isnot(None), + Subscription.end_date <= now, + ] + + query = ( + select(User) + .options(selectinload(User.subscription)) + .join(Subscription, Subscription.user_id == User.id) + .where(*base_filters) + .order_by(User.balance_kopeks.desc(), Subscription.end_date.asc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(query) + users = result.scalars().all() + + count_query = ( + select(func.count(User.id)) + .join(Subscription, Subscription.user_id == User.id) + .where(*base_filters) + ) + total_count = (await db.execute(count_query)).scalar() or 0 + total_pages = (total_count + limit - 1) // limit if total_count else 0 + + return { + "users": users, + "current_page": page, + "total_pages": total_pages, + "total_count": total_count, + } + + except Exception as e: + logger.error(f"Ошибка получения пользователей для продления: {e}") + return { + "users": [], + "current_page": 1, + "total_pages": 1, + "total_count": 0, + } + async def get_user_spending_stats_map( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 178748e7..42122686 100644 --- a/app/states.py +++ b/app/states.py @@ -142,6 +142,7 @@ class AdminStates(StatesGroup): viewing_user_from_spending_list = State() viewing_user_from_purchases_list = State() viewing_user_from_campaign_list = State() + viewing_user_from_ready_to_renew_list = State() class SupportStates(StatesGroup): waiting_for_message = State()