Merge pull request #814 from Fr1ngg/bedolaga

Add per-user promo template sending in admin panel
This commit is contained in:
Egor
2025-10-07 00:19:18 +03:00
committed by GitHub
4 changed files with 505 additions and 17 deletions

View File

@@ -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 = "📱 <b>Подписка и настройки пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\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",
"🎯 <b>Активное промо:</b> {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",
"🎯 <b>Активное промо:</b> отсутствует",
)
)
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",
"🎯 <b>Промо-предложения для {name}</b>",
).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_")

View File

@@ -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", "📊 Статистика"),

View File

@@ -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": "👥 <b>User promo group</b>",
"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": "<b>Subscription:</b> None",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 <b>Active promo:</b> {percent}% (source: {source})\nValid until: {expires_at}",
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 <b>Active promo:</b> none",
"ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "unknown",
"ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "no expiry",
"ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 <b>Promo offers for {name}</b>",
"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",

View File

@@ -96,6 +96,7 @@
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
"DELETE_MESSAGE": "🗑 Удалить",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Промо-предложения",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
@@ -784,6 +785,20 @@
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 <b>Активное промо:</b> {percent}% (источник: {source})\nДействует до: {expires_at}",
"ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 <b>Активное промо:</b> отсутствует",
"ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "неизвестно",
"ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "без ограничения",
"ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 <b>Промо-предложения для {name}</b>",
"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": "🎁 Триал",