Enhance promo code management and user statistics features

- Introduced a unified promo code management system, allowing admins to view and manage both active and inactive promo codes with detailed information.
- Updated the promo code detail view to include status indicators and management options for editing, toggling status, and viewing activation history.
- Enhanced user statistics display by restructuring the information layout and adding new labels for clarity.
- Improved localization files to support new messages and labels related to promo code management and user statistics.
- Implemented error handling for suspicious promo code attempts, integrating a notification system for better admin awareness.
This commit is contained in:
machka-pasla
2025-08-03 21:09:03 +03:00
parent aa97618165
commit a5b408d204
11 changed files with 442 additions and 108 deletions
+150 -16
View File
@@ -465,10 +465,12 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
await callback.answer()
@router.callback_query(F.data == "admin_action:manage_promos")
async def manage_promo_codes_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
# New unified promo management system
@router.callback_query(F.data == "admin_action:promo_management")
async def promo_management_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
"""Show list of all promo codes for management"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
@@ -476,36 +478,168 @@ async def manage_promo_codes_handler(callback: types.CallbackQuery,
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
limit=20,
offset=0)
# Get ALL promo codes (including inactive)
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0)
if not promo_models:
await callback.message.edit_text(
_("admin_no_active_promos"),
_("admin_promo_management_empty", default="📭 Промокоды отсутствуют"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
return
kb = InlineKeyboardBuilder()
for promo in promo_models:
# Show promo code with status indicator
status_emoji = "" if promo.is_active else "🚫"
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
status_emoji = "" # Expired
elif promo.current_activations >= promo.max_activations:
status_emoji = "🔄" # Used up
button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
kb.row(
InlineKeyboardButton(
text=promo.code,
callback_data=f"promo_edit:{promo.promo_code_id}"),
InlineKeyboardButton(
text=_("admin_promo_delete_button"),
callback_data=f"promo_delete:{promo.promo_code_id}"),
text=button_text,
callback_data=f"promo_detail:{promo.promo_code_id}")
)
kb.row(
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
InlineKeyboardButton(text=_("back_to_admin_panel_button", default="⬅️ Назад"),
callback_data="admin_action:main"))
await callback.message.edit_text(
_("admin_manage_promos_title"),
reply_markup=kb.as_markup())
_("admin_promo_management_title", default="🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:"),
reply_markup=kb.as_markup(),
parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data.startswith("promo_detail:"))
async def promo_detail_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
"""Show detailed promo code information with management options"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying promo details.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
await callback.answer(_("admin_promo_not_found", default="Промокод не найден"), show_alert=True)
return
# Determine status
status = _("admin_promo_status_active", default="✅ Активен")
if not promo.is_active:
status = _("admin_promo_status_inactive", default="🚫 Неактивен")
elif promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
status = _("admin_promo_status_expired", default="⏰ Истек")
elif promo.current_activations >= promo.max_activations:
status = _("admin_promo_status_used_up", default="🔄 Исчерпан")
# Format validity
validity = _("admin_promo_valid_indefinitely", default="Неограниченно")
if promo.valid_until:
validity = promo.valid_until.strftime('%Y-%m-%d %H:%M')
# Format created date
created_date = promo.created_at.strftime('%Y-%m-%d %H:%M') if promo.created_at else "N/A"
creator = f"Admin {promo.created_by_admin_id}" if promo.created_by_admin_id else "N/A"
# Build card text
card_text = _(
"admin_promo_card_title",
default="🎟 <b>Промокод: {code}</b>",
code=promo.code
) + "\n\n"
card_text += _(
"admin_promo_card_bonus_days",
default="🎁 Бонусные дни: <b>{days}</b>",
days=promo.bonus_days
) + "\n"
card_text += _(
"admin_promo_card_activations",
default="🔢 Активации: <b>{current}/{max}</b>",
current=promo.current_activations,
max=promo.max_activations
) + "\n"
card_text += _(
"admin_promo_card_validity",
default="⏰ Действует до: <b>{validity}</b>",
validity=validity
) + "\n"
card_text += _(
"admin_promo_card_status",
default="📊 Статус: <b>{status}</b>",
status=status
) + "\n"
card_text += _(
"admin_promo_card_created",
default="📅 Создан: <b>{created}</b>",
created=created_date
) + "\n"
card_text += _(
"admin_promo_card_created_by",
default="👤 Создал: <b>{creator}</b>",
creator=creator
)
# Build keyboard
kb = InlineKeyboardBuilder()
# Row 1: Edit and Toggle status
kb.row(
InlineKeyboardButton(
text=_("admin_promo_edit_button", default="✏️ Редактировать"),
callback_data=f"promo_edit:{promo_id}"),
InlineKeyboardButton(
text=_("admin_promo_toggle_status_button", default="🔄 Вкл/Выкл"),
callback_data=f"promo_toggle:{promo_id}")
)
# Row 2: View activations and Delete
kb.row(
InlineKeyboardButton(
text=_("admin_promo_view_activations_button", default="📋 Активации"),
callback_data=f"promo_activations:{promo_id}"),
InlineKeyboardButton(
text=_("admin_promo_delete_button", default="🗑 Удалить"),
callback_data=f"promo_delete:{promo_id}")
)
# Row 3: Back to list
kb.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_list_button", default="⬅️ К списку"),
callback_data="admin_action:promo_management")
)
await callback.message.edit_text(
card_text,
reply_markup=kb.as_markup(),
parse_mode="HTML")
await callback.answer()
# Legacy manage_promo_codes_handler - keeping for compatibility
@router.callback_query(F.data == "admin_action:manage_promos")
async def manage_promo_codes_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
# Redirect to new unified handler
await promo_management_handler(callback, i18n_data, settings, session)
@router.callback_query(F.data.startswith("promo_edit:"))
async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext,
i18n_data: dict, settings: Settings,
+85 -15
View File
@@ -37,27 +37,97 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
)
stats_text_parts.append(
f"📊 Всего: <b>{user_stats['total_users']}</b>"
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"📈 Активных сегодня: <b>{user_stats['active_today']}</b>"
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"💳 С платной подпиской: <b>{user_stats['paid_subscriptions']}</b>"
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🆓 На пробном периоде: <b>{user_stats['trial_users']}</b>"
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"😴 Неактивных: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 Заблокированных: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 Привлечено по реферальной программе: <b>{user_stats['referral_users']}</b>"
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
try:
async with PanelApiService(settings) as panel_service:
# Get system stats
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}")
if system_stats:
users = system_stats.get('users', {})
active_users = users.get('active', 0)
disabled_users = users.get('disabled', 0)
expired_users = users.get('expired', 0)
limited_users = users.get('limited', 0)
total_users = users.get('total', 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{active_users}</b>")
stats_text_parts.append(f"🔴 {_('admin_panel_offline_label', default='Офлайн')}: <b>{disabled_users}</b>")
stats_text_parts.append(f"{_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
# System resources
cpu = system_stats.get('cpu', {})
memory = system_stats.get('memory', {})
if cpu:
cpu_usage = cpu.get('usage', 0)
stats_text_parts.append(f"🔄 {_('admin_panel_cpu_usage_label', default='Загрузка CPU')}: <b>{cpu_usage:.1f}%</b>")
if memory:
memory_usage = memory.get('usage', 0)
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
# Bandwidth stats
if bandwidth_stats:
today_traffic = bandwidth_stats.get('bandwidthToday', {})
week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {})
month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {})
if today_traffic:
today_total = today_traffic.get('total', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_today_label', default='Трафик сегодня')}: <b>{today_total}</b>")
if week_traffic:
week_total = week_traffic.get('total', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
if month_traffic:
month_total = month_traffic.get('total', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
# Nodes stats
if nodes_stats:
last_seven_days = nodes_stats.get('lastSevenDays', [])
active_nodes_count = len([node for node in last_seven_days if node.get('status') == 'active'])
total_nodes_count = len(last_seven_days)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{active_nodes_count}/{total_nodes_count}</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_nodes_stats_error', default='Ошибка получения статистики нод')}")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
@@ -65,16 +135,16 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
)
stats_text_parts.append(
f"📅 За сегодня: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} платежей)"
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
)
stats_text_parts.append(
f"📅 За неделю: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 За месяц: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 За все время: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
)
last_payments_models: List[
+14 -10
View File
@@ -92,8 +92,7 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
"✨ Быстрый и надежный\n"
"🔒 Полная анонимность\n"
"🌍 Серверы по всему миру\n"
"💎 Бесплатный пробный период\n\n"
"Переходи по ссылке: {referral_link}",
"💎 Бесплатный пробный период\n\n{referral_link}",
referral_link=referral_link
)
@@ -180,8 +179,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s
default="👥 Статистика пользователей"
),
description=_(
"inline_admin_user_stats_desc",
default=f"Всего: {user_stats['total_users']}, Активных: {user_stats['paid_subscriptions']}"
"inline_stats_description",
default="Всего: {total}, Активных: {active}",
total=user_stats['total_users'],
active=user_stats['paid_subscriptions']
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
@@ -225,8 +226,9 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la
default="💰 Финансовая статистика"
),
description=_(
"inline_admin_financial_stats_desc",
default=f"Сегодня: {financial_stats['today_revenue']:.2f} RUB"
"inline_financial_description",
default="Сегодня: {today} RUB",
today=f"{financial_stats['today_revenue']:.2f}"
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
@@ -296,8 +298,10 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang:
default="🖥 Системная статистика"
),
description=_(
"inline_admin_system_stats_desc",
default=f"Онлайн: {active_subs}, Офлайн: {max(0, offline_users)}"
"inline_system_description",
default="Онлайн: {online}, Офлайн: {offline}",
online=active_subs,
offline=max(0, offline_users)
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
@@ -327,7 +331,7 @@ async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> Inline
"💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!"
)
title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)")
description = _("inline_admin_help_desc", default="Доступны команды: реф, стат, финансы, система")
description = _("inline_admin_help_description", default="Доступны команды: реф, стат, финансы, система")
else:
help_text = _(
"inline_user_help_message",
@@ -337,7 +341,7 @@ async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> Inline
"💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!"
)
title = _("inline_user_help_title", default="🤖 Inline помощь")
description = _("inline_user_help_desc", default="Доступна команда: реф (реферальная ссылка)")
description = _("inline_user_help_description", default="Доступна команда: реф (реферальная ссылка)")
return InlineQueryResultArticle(
id="help",
+14 -21
View File
@@ -105,30 +105,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
response_to_user_text = ""
if is_suspicious:
if settings.ADMIN_IDS:
admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username"
admin_lang = settings.DEFAULT_LANGUAGE
_admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
admin_notification_text = _admin(
admin_notify_key,
user_id=user.id,
user_username=user.username or "N/A",
user_first_name=user.first_name or "N/A",
promo_code_input=hcode(code_input))
for admin_id in settings.ADMIN_IDS:
try:
await bot.send_message(admin_id,
admin_notification_text,
parse_mode="HTML")
except Exception as e_admin_notify:
logging.error(
f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}"
)
# Send notification through NotificationService if enabled
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
response_to_user_text = _("promo_code_not_found",
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
success, result = await promo_code_service.apply_promo_code(