diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 6ba6c31e..d6660767 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -70,6 +70,7 @@ class UserFilterType(Enum): SPENDING = 'spending' PURCHASES = 'purchases' CAMPAIGN = 'campaign' + POTENTIAL_CUSTOMERS = 'potential_customers' @dataclass @@ -127,6 +128,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = { pagination_prefix='admin_users_campaign_list', order_param='', # использует специальный метод ), + UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig( + fsm_state=AdminStates.viewing_user_from_potential_customers_list, + title='👥 Потенциальные клиенты', + empty_message='💰 Потенциальные клиенты не найдены', + pagination_prefix='admin_users_potential_customers_list', + order_param='', # использует специальный метод + ), } @@ -574,6 +582,125 @@ async def show_users_ready_to_renew( await callback.answer() +@admin_required +@error_handler +async def show_potential_customers( + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 +): + """Показывает пользователей без активной подписки с балансом >= месячной цены.""" + await state.set_state(AdminStates.viewing_user_from_potential_customers_list) + + texts = get_texts(db_user.language) + from app.config import PERIOD_PRICES + + monthly_price = PERIOD_PRICES.get(30, 99000) + + user_service = UserService() + users_data = await user_service.get_potential_customers( + db, + min_balance_kopeks=monthly_price, + page=page, + limit=10, + ) + + amount_text = settings.format_price(monthly_price) + header = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE', + '💰 Потенциальные клиенты', + ) + description = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC', + 'Пользователи без активной подписки с балансом {amount} или больше.', + ).format(amount=amount_text) + + if not users_data['users']: + empty_text = texts.t( + 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_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 = [] + + for user in users_data['users']: + subscription = user.subscription + status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫' + subscription_emoji = '❌' + + if subscription: + if subscription.is_trial: + subscription_emoji = '🎁' + elif subscription.is_active: + subscription_emoji = '💎' + else: + subscription_emoji = '⏰' + + button_text = ( + f'{status_emoji} {subscription_emoji} {user.full_name}' + f' | 💰 {settings.format_price(user.balance_kopeks)}' + ) + + 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} | 💰 {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_potential_customers_list', + 'admin_users_potential_customers_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( @@ -716,6 +843,19 @@ async def handle_users_ready_to_renew_pagination( await show_users_ready_to_renew(callback, db_user, db, state, 1) +@admin_required +@error_handler +async def handle_potential_customers_pagination( + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext +): + try: + page = int(callback.data.split('_')[-1]) + await show_potential_customers(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f'Ошибка парсинга номера страницы: {e}') + await show_potential_customers(callback, db_user, db, state, 1) + + @admin_required @error_handler async def handle_users_campaign_list_pagination( @@ -1322,6 +1462,8 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: 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' + elif current_state == AdminStates.viewing_user_from_potential_customers_list: + back_callback = 'admin_users_potential_customers_filter' # Базовая клавиатура профиля kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) @@ -5503,6 +5645,10 @@ def register_handlers(dp: Dispatcher): handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_') ) + dp.callback_query.register( + handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_') + ) + dp.callback_query.register( handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_') ) @@ -5669,4 +5815,6 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter') + dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter') + dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter') diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 4a1a17d9..389f90e6 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_ready_to_renew_filter', ) ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'), + callback_data='admin_users_potential_customers_filter', + ) + ], [ InlineKeyboardButton( text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'), diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index a3ff6f10..5d780c11 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -15,7 +15,7 @@ from app.database.crud.campaign import get_campaign_by_start_parameter from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id from app.database.database import AsyncSessionLocal -from app.database.models import SubscriptionStatus +from app.database.models import SubscriptionStatus, UserStatus from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts @@ -396,6 +396,14 @@ class ChannelCheckerMiddleware(BaseMiddleware): if not user or not user.subscription: return + # НЕ реактивируем подписку заблокированным пользователям + if user.status == UserStatus.BLOCKED.value: + logger.info( + '🚫 Пропуск реактивации для заблокированного пользователя %s', + telegram_id, + ) + return + subscription = user.subscription # Реактивируем только DISABLED подписки diff --git a/app/services/user_service.py b/app/services/user_service.py index 878bf702..849799bd 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -344,6 +344,72 @@ class UserService: 'total_count': 0, } + async def get_potential_customers( + self, + db: AsyncSession, + min_balance_kopeks: int, + page: int = 1, + limit: int = 10, + ) -> dict[str, Any]: + """Возвращает пользователей без активной подписки с достаточным балансом.""" + try: + offset = (page - 1) * limit + + # Фильтры: нет активной подписки И баланс >= порога + base_filters = [ + User.balance_kopeks >= min_balance_kopeks, + ] + + # Основной запрос с LEFT JOIN для поддержки пользователей без подписки + query = ( + select(User) + .options(selectinload(User.subscription)) + .outerjoin(Subscription, Subscription.user_id == User.id) + .where( + *base_filters, + or_( + User.subscription == None, + ~Subscription.status.in_(['active', 'trial']), + ), + ) + .order_by(User.balance_kopeks.desc(), User.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(query) + users = result.scalars().unique().all() + + # Запрос для подсчета общего количества + count_query = ( + select(func.count(User.id)) + .outerjoin(Subscription, Subscription.user_id == User.id) + .where( + *base_filters, + or_( + User.subscription == None, + ~Subscription.status.in_(['active', 'trial']), + ), + ) + ) + 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, user_ids: list[int]) -> dict[int, dict[str, int]]: try: return await get_users_spending_stats(db, user_ids) diff --git a/app/states.py b/app/states.py index b29c9a64..f512ed95 100644 --- a/app/states.py +++ b/app/states.py @@ -186,6 +186,7 @@ class AdminStates(StatesGroup): viewing_user_from_purchases_list = State() viewing_user_from_campaign_list = State() viewing_user_from_ready_to_renew_list = State() + viewing_user_from_potential_customers_list = State() # Состояния для управления тарифами creating_tariff_name = State()