Новый фильтр Готовы к продлению

This commit is contained in:
gy9vin
2025-12-11 22:42:37 +03:00
parent c9de084efa
commit 5dd586e0b2
9 changed files with 242 additions and 4 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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", "📢 По кампании"),

View File

@@ -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",

View File

@@ -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": "💳 По сумме трат",

View File

@@ -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} зараховано на ваш баланс."
}
}

View File

@@ -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":"🕐活跃:很久以前"
}
}

View File

@@ -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,

View File

@@ -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()