diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 1cb146f7..d7516549 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,39 +1,60 @@ import logging from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, List, Tuple + from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id from app.database.crud.campaign import ( get_campaign_registration_by_user, get_campaign_statistics, ) -from app.keyboards.admin import ( - get_admin_users_keyboard, get_user_management_keyboard, - get_admin_pagination_keyboard, get_confirmation_keyboard, - get_admin_users_filters_keyboard, get_user_promo_group_keyboard -) -from app.localization.texts import get_texts -from app.services.user_service import UserService -from app.services.admin_notification_service import AdminNotificationService +from app.database.crud.discount_offer import upsert_discount_offer from app.database.crud.promo_group import get_promo_groups_with_counts -from app.utils.decorators import admin_required, error_handler -from app.utils.formatters import format_datetime, format_time_ago -from app.services.remnawave_service import RemnaWaveService -from app.external.remnawave_api import TrafficLimitStrategy +from app.database.crud.promo_offer_template import ( + ensure_default_templates, + get_promo_offer_template_by_id, + list_promo_offer_templates, +) from app.database.crud.server_squad import ( get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id, get_server_ids_by_uuids, ) +from app.database.crud.user import get_user_by_id +from app.database.models import ( + Subscription, + SubscriptionStatus, + TransactionType, + User, + UserStatus, +) +from app.external.remnawave_api import TrafficLimitStrategy +from app.keyboards.admin import ( + get_admin_pagination_keyboard, + get_admin_users_filters_keyboard, + get_admin_users_keyboard, + get_confirmation_keyboard, + get_user_management_keyboard, + get_user_promo_group_keyboard, +) +from app.localization.texts import get_texts +from app.services.admin_notification_service import AdminNotificationService +from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService +from app.services.user_service import UserService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_time_ago +from app.utils.promo_offer import ( + build_promo_offer_timer_line, + get_user_active_promo_discount_percent, +) logger = logging.getLogger(__name__) @@ -997,6 +1018,12 @@ async def _render_user_subscription_overview( user = profile["user"] subscription = profile["subscription"] + if subscription is not None: + try: + setattr(user, "subscription", subscription) + except Exception: + pass + text = "📱 Подписка и настройки пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1448,6 +1475,44 @@ async def show_user_management( else: sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE) + promo_percent = get_user_active_promo_discount_percent(user) + promo_expires_at = getattr(user, "promo_offer_discount_expires_at", None) + promo_source = getattr(user, "promo_offer_discount_source", None) + + if promo_percent > 0: + expires_text = ( + format_datetime(promo_expires_at) + if promo_expires_at + else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞") + ) + source_text = ( + promo_source + if promo_source + else texts.get("ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN", "—") + ) + promo_section = texts.get( + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE", + "🎯 Активное промо: {percent}% (источник: {source})\nДействует до: {expires_at}", + ).format( + percent=promo_percent, + source=source_text, + expires_at=expires_text, + ) + try: + timer_line = await build_promo_offer_timer_line(db, user, texts) + except Exception: + timer_line = None + if timer_line: + promo_section += f"\n{timer_line}" + sections.append(promo_section) + else: + sections.append( + texts.get( + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE", + "🎯 Активное промо: отсутствует", + ) + ) + text = "\n\n".join(sections) # Проверяем состояние, чтобы определить, откуда пришел пользователь @@ -1485,6 +1550,76 @@ async def show_user_management( await callback.answer() +PROMO_TEMPLATE_ICONS = { + "test_access": "🧪", + "extend_discount": "💎", + "purchase_discount": "🎯", +} + +PROMO_TEMPLATE_EFFECT_TYPES = { + "test_access": "test_access", + "extend_discount": "percent_discount", + "purchase_discount": "percent_discount", +} + + +def _build_user_template_button_text(template) -> str: + icon = PROMO_TEMPLATE_ICONS.get(template.offer_type, "📨") + suffix = "" + try: + percent = int(template.discount_percent or 0) + except (TypeError, ValueError): + percent = 0 + if template.offer_type != "test_access" and percent > 0: + suffix = f" ({percent}%)" + return f"{icon} {template.name}{suffix}" + + +def _render_promo_template_text(template, language: str, *, server_name: Optional[str] = None) -> str: + replacements = { + "discount_percent": template.discount_percent, + "valid_hours": template.valid_hours, + "test_duration_hours": template.test_duration_hours or 0, + "active_discount_hours": template.active_discount_hours or template.valid_hours, + } + + if server_name is not None: + replacements.setdefault("server_name", server_name) + else: + replacements.setdefault("server_name", "???") + + try: + return template.message_text.format(**replacements) + except Exception: + logger.warning( + "Не удалось форматировать текст промо-предложения %s для языка %s", + template.id, + language, + ) + return template.message_text + + +async def _resolve_template_squad_info( + db: AsyncSession, + template, +) -> Tuple[Optional[str], Optional[str]]: + if template.offer_type != "test_access": + return None, None + + squad_uuids = template.test_squad_uuids or [] + if not squad_uuids: + return None, None + + squad_uuid = str(squad_uuids[0]) + server = await get_server_squad_by_uuid(db, squad_uuid) + server_name = server.display_name if server else None + return squad_uuid, server_name + + +def _get_template_effect_type(template) -> str: + return PROMO_TEMPLATE_EFFECT_TYPES.get(template.offer_type, "percent_discount") + + async def _render_user_promo_group( message: types.Message, language: str, @@ -1615,6 +1750,313 @@ async def set_user_promo_group( ) +@admin_required +@error_handler +async def show_user_promo_templates( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + try: + user_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user: User = profile["user"] + subscription: Optional[Subscription] = profile.get("subscription") + if subscription is not None: + try: + setattr(target_user, "subscription", subscription) + except Exception: + pass + + await ensure_default_templates(db, created_by=db_user.id) + templates = await list_promo_offer_templates(db) + + texts = get_texts(db_user.language) + active_templates = [template for template in templates if getattr(template, "is_active", True)] + + if not active_templates: + await callback.answer( + texts.t("ADMIN_USER_PROMO_TEMPLATES_EMPTY", "Нет активных шаблонов промо-предложений."), + show_alert=True, + ) + return + + promo_percent = get_user_active_promo_discount_percent(target_user) + promo_expires_at = getattr(target_user, "promo_offer_discount_expires_at", None) + expires_text = ( + format_datetime(promo_expires_at) + if promo_expires_at + else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞") + ) + + message_lines = [ + texts.t( + "ADMIN_USER_PROMO_TEMPLATES_TITLE", + "🎯 Промо-предложения для {name}", + ).format(name=target_user.full_name), + "", + texts.t( + "ADMIN_USER_PROMO_TEMPLATES_HINT", + "Выберите шаблон, чтобы отправить пользователю промо-предложение.", + ), + ] + + if promo_percent > 0: + message_lines.extend( + [ + "", + texts.t( + "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO", + "Активная скидка: {percent}% (до {expires_at})", + ).format(percent=promo_percent, expires_at=expires_text), + ] + ) + + keyboard_rows: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text=_build_user_template_button_text(template), + callback_data=f"admin_user_promo_send_{target_user.id}_{template.id}", + ) + ] + for template in active_templates + ] + + keyboard_rows.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_manage_{target_user.id}", + ) + ]) + + await state.set_state(None) + + await callback.message.edit_text( + "\n".join(message_lines), + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def send_user_promo_template( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + prefix = "admin_user_promo_send_" + if not callback.data.startswith(prefix): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + payload = callback.data[len(prefix):] + try: + user_part, template_part = payload.split("_", 1) + user_id = int(user_part) + template_id = int(template_part) + except (ValueError, AttributeError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user: User = profile["user"] + subscription: Optional[Subscription] = profile.get("subscription") + if subscription is not None: + try: + setattr(target_user, "subscription", subscription) + except Exception: + pass + + template = await get_promo_offer_template_by_id(db, template_id) + if not template or not getattr(template, "is_active", True): + await callback.answer("❌ Шаблон недоступен", show_alert=True) + return + + squad_uuid, squad_name = await _resolve_template_squad_info(db, template) + + if template.offer_type == "test_access" and squad_uuid: + connected = set(subscription.connected_squads or []) if subscription else set() + if squad_uuid in connected: + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS", + "У пользователя уже есть доступ к этому серверу.", + ), + show_alert=True, + ) + return + + effect_type = _get_template_effect_type(template) + + try: + offer_record = await upsert_discount_offer( + db, + user_id=target_user.id, + subscription_id=subscription.id if subscription else None, + notification_type=f"promo_template_{template.id}", + discount_percent=template.discount_percent, + bonus_amount_kopeks=0, + valid_hours=template.valid_hours, + effect_type=effect_type, + extra_data={ + "template_id": template.id, + "offer_type": template.offer_type, + "test_duration_hours": template.test_duration_hours, + "test_squad_uuids": template.test_squad_uuids, + "active_discount_hours": template.active_discount_hours, + }, + ) + except Exception as exc: + logger.error( + "Failed to upsert discount offer for template %s and user %s: %s", + template_id, + user_id, + exc, + ) + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR", + "Не удалось подготовить промо-предложение.", + ), + show_alert=True, + ) + return + + user_language = target_user.language or db_user.language + user_texts = get_texts(user_language) + keyboard_rows: List[List[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text=template.button_text, + callback_data=f"claim_discount_{offer_record.id}", + ) + ], + [ + InlineKeyboardButton( + text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"), + callback_data="promo_offer_close", + ) + ], + ] + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + message_text = _render_promo_template_text( + template, + user_language, + server_name=squad_name, + ) + + try: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + except TelegramForbiddenError: + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED", + "Пользователь заблокировал бота. Сообщение не отправлено.", + ), + show_alert=True, + ) + return + except TelegramBadRequest as exc: + logger.warning( + "Telegram API error while sending promo template %s to %s: %s", + template_id, + target_user.telegram_id, + exc, + ) + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED", + "Не удалось отправить сообщение пользователю.", + ), + show_alert=True, + ) + return + except Exception as exc: + logger.error( + "Unexpected error while sending promo template %s to %s: %s", + template_id, + target_user.telegram_id, + exc, + ) + texts = get_texts(db_user.language) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR", + "Не удалось отправить промо-предложение пользователю.", + ), + show_alert=True, + ) + return + + texts = get_texts(db_user.language) + success_text = texts.t( + "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS", + "✅ Промо-предложение «{name}» отправлено пользователю {user}.", + ).format(name=template.name, user=target_user.full_name) + + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN", + "📨 Отправить другое", + ), + callback_data=f"admin_user_promo_templates_{target_user.id}", + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_manage_{target_user.id}", + ) + ], + ] + ) + + await state.set_state(None) + + await callback.message.edit_text( + success_text, + reply_markup=reply_markup, + parse_mode="HTML", + ) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE", + "Промо-предложение отправлено.", + ) + ) + + @admin_required @error_handler @@ -3969,6 +4411,16 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_user_promo_group_set_") ) + dp.callback_query.register( + show_user_promo_templates, + F.data.startswith("admin_user_promo_templates_") + ) + + dp.callback_query.register( + send_user_promo_template, + F.data.startswith("admin_user_promo_send_") + ) + dp.callback_query.register( start_balance_edit, F.data.startswith("admin_user_balance_") diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 2f5200cf..4a5f45fb 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -724,6 +724,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = callback_data=f"admin_user_promo_group_{user_id}" ) ], + [ + InlineKeyboardButton( + text=texts.get("ADMIN_USER_PROMO_TEMPLATES_BUTTON", "🎯 Промо-предложения"), + callback_data=f"admin_user_promo_templates_{user_id}" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), diff --git a/locales/en.json b/locales/en.json index c62cfe8d..276352b6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -240,6 +240,7 @@ "ATTACHMENTS_SENT": "✅ Attachments sent.", "DELETE_MESSAGE": "🗑 Delete", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Promo offers", "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", @@ -784,6 +785,20 @@ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None", "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%", "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned", + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 Active promo: {percent}% (source: {source})\nValid until: {expires_at}", + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 Active promo: none", + "ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "unknown", + "ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "no expiry", + "ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 Promo offers for {name}", + "ADMIN_USER_PROMO_TEMPLATES_HINT": "Choose a template to send a promo offer to the user.", + "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Active discount: {percent}% (until {expires_at})", + "ADMIN_USER_PROMO_TEMPLATES_EMPTY": "No active promo offer templates.", + "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "The user already has access to this server.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Failed to send the promo offer to the user.", + "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Failed to deliver the message to the user.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Promo offer “{name}” sent to {user}.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Send another", + "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Promo offer sent.", "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial", diff --git a/locales/ru.json b/locales/ru.json index 7a075381..e90dbd37 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -96,6 +96,7 @@ "ATTACHMENTS_SENT": "✅ Вложения отправлены.", "DELETE_MESSAGE": "🗑 Удалить", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа", + "ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Промо-предложения", "ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя", "ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена", @@ -784,6 +785,20 @@ "ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует", "ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%", "ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена", + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 Активное промо: {percent}% (источник: {source})\nДействует до: {expires_at}", + "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 Активное промо: отсутствует", + "ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "неизвестно", + "ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "без ограничения", + "ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 Промо-предложения для {name}", + "ADMIN_USER_PROMO_TEMPLATES_HINT": "Выберите шаблон, чтобы отправить промо-предложение пользователю.", + "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Активная скидка: {percent}% (до {expires_at})", + "ADMIN_USER_PROMO_TEMPLATES_EMPTY": "Нет активных шаблонов промо-предложений.", + "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "У пользователя уже есть доступ к этому серверу.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Не удалось отправить промо-предложение пользователю.", + "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Не удалось отправить сообщение пользователю.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Промо-предложение «{name}» отправлено пользователю {user}.", + "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Отправить другое", + "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Промо-предложение отправлено.", "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",