Files
2026-03-26 10:34:14 +03:00

2500 lines
93 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import html
import re
from collections.abc import Sequence
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.discount_offer import list_discount_offers, upsert_discount_offer
from app.database.crud.promo_offer_log import list_promo_offer_logs
from app.database.crud.promo_offer_template import (
ensure_default_templates,
get_promo_offer_template_by_id,
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
from app.database.crud.user import get_user_by_id, get_users_for_promo_segment
from app.database.models import (
DiscountOffer,
PromoOfferLog,
PromoOfferTemplate,
SubscriptionTemporaryAccess,
User,
UserStatus,
)
from app.keyboards.inline import get_happ_download_button_row
from app.localization.texts import get_texts
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_duration
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.subscription_utils import get_display_subscription_link
logger = structlog.get_logger(__name__)
SQUADS_PAGE_LIMIT = 10
PROMO_OFFER_LOGS_PAGE_LIMIT = 10
PROMO_OFFER_USER_PAGE_LIMIT = 10
async def _safe_delete_message(message: Message) -> None:
try:
await message.delete()
except TelegramBadRequest as exc:
if 'message to delete not found' not in str(exc).lower():
logger.debug('Не удалось удалить сообщение администратора', exc=exc)
except TelegramForbiddenError:
logger.debug('Недостаточно прав для удаления сообщения администратора')
async def _safe_delete_message_by_id(bot, chat_id: int, message_id: int) -> None:
try:
await bot.delete_message(chat_id, message_id)
except TelegramBadRequest as exc:
if 'message to delete not found' not in str(exc).lower():
logger.debug(
'Не удалось удалить сообщение администратора (,)', chat_id=chat_id, message_id=message_id, exc=exc
)
except TelegramForbiddenError:
logger.debug(
'Недостаточно прав для удаления сообщения администратора (,)', chat_id=chat_id, message_id=message_id
)
async def _clear_promo_offer_search_prompt(state: FSMContext, bot) -> None:
data = await state.get_data()
prompt_info = data.get('promo_offer_user_search_prompt') or {}
chat_id = prompt_info.get('chat_id')
message_id = prompt_info.get('message_id')
if chat_id and message_id:
await _safe_delete_message_by_id(bot, chat_id, message_id)
if prompt_info:
await state.update_data(promo_offer_user_search_prompt=None)
ACTION_LABEL_KEYS = {
'claimed': 'ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED',
'consumed': 'ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED',
'disabled': 'ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED',
}
REASON_LABEL_KEYS = {
'manual_charge': 'ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL',
'autopay_consumed': 'ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY',
'offer_expired': 'ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED',
'test_access_expired': 'ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED',
}
OFFER_TYPE_CONFIG = {
'test_access': {
'icon': '🧪',
'label_key': 'ADMIN_PROMO_OFFER_TEST_ACCESS',
'default_label': 'Тестовые сервера',
'allowed_segments': [
('paid_active', '🟢 Активные платные'),
('trial_active', '🎁 Активные триалы'),
],
'effect_type': 'test_access',
},
'extend_discount': {
'icon': '💎',
'label_key': 'ADMIN_PROMO_OFFER_EXTEND',
'default_label': 'Скидка на продление',
'allowed_segments': [
('paid_active', '🟢 Активные платные'),
],
'effect_type': 'percent_discount',
},
'purchase_discount': {
'icon': '🎯',
'label_key': 'ADMIN_PROMO_OFFER_PURCHASE',
'default_label': 'Скидка на покупку',
'allowed_segments': [
('paid_expired', '🔴 Истёкшие платные'),
('trial_expired', '🥶 Истёкшие триалы'),
('trial_active', '🎁 Активные триалы'),
],
'effect_type': 'percent_discount',
},
}
def _render_template_text(
template: PromoOfferTemplate,
language: str,
*,
server_name: str | None = 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:
# Prevent KeyError if template expects server_name
replacements.setdefault('server_name', '???')
try:
return template.message_text.format(**replacements)
except Exception: # pragma: no cover - fallback for invalid placeholders
logger.warning('Не удалось форматировать текст промо-предложения', template_id=template.id)
return template.message_text
async def _resolve_template_squad(
db: AsyncSession,
template: PromoOfferTemplate,
) -> tuple[str | None, str | None]:
if template.offer_type != 'test_access':
return None, None
squads = template.test_squad_uuids or []
if not squads:
return None, None
squad_uuid = str(squads[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 _build_templates_keyboard(templates: Sequence[PromoOfferTemplate], language: str) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
for template in templates:
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
icon = config.get('icon', '📨')
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
rows.append(
[
InlineKeyboardButton(
text=f'{icon} {label}',
callback_data=f'promo_offer_{template.id}',
)
]
)
rows.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_LOGS', '📜 Лог операций'),
callback_data='promo_offer_logs_page_1',
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_communications')])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup:
texts = get_texts(language)
OFFER_TYPE_CONFIG.get(template.offer_type, {})
rows: list[list[InlineKeyboardButton]] = []
rows.append(
[
InlineKeyboardButton(text='✏️ Текст', callback_data=f'promo_offer_edit_message_{template.id}'),
InlineKeyboardButton(text='🪄 Кнопка', callback_data=f'promo_offer_edit_button_{template.id}'),
]
)
rows.append(
[
InlineKeyboardButton(text='⏱️ Срок', callback_data=f'promo_offer_edit_valid_{template.id}'),
]
)
if template.offer_type != 'test_access':
rows[-1].append(InlineKeyboardButton(text='📉 %', callback_data=f'promo_offer_edit_discount_{template.id}'))
rows.append(
[
InlineKeyboardButton(text='⌛ Активна', callback_data=f'promo_offer_edit_active_{template.id}'),
]
)
else:
rows.append(
[
InlineKeyboardButton(text='⏳ Длительность', callback_data=f'promo_offer_edit_duration_{template.id}'),
InlineKeyboardButton(text='🌍 Сквады', callback_data=f'promo_offer_edit_squads_{template.id}'),
]
)
rows.append(
[
InlineKeyboardButton(text='📬 Отправить', callback_data=f'promo_offer_send_menu_{template.id}'),
]
)
rows.append(
[
InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_offers'),
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
def _format_offer_remaining(offer, texts) -> str:
if not offer.expires_at:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока')
remaining_seconds = int((offer.expires_at - datetime.now(UTC)).total_seconds())
if remaining_seconds <= 0:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED', 'истекло')
return format_duration(remaining_seconds)
def _extract_offer_active_hours(offer, template: PromoOfferTemplate | None) -> int | None:
extra = offer.extra_data or {}
active_hours = extra.get('active_discount_hours')
if active_hours:
try:
return int(active_hours)
except (TypeError, ValueError):
pass
if template and template.active_discount_hours:
return template.active_discount_hours
if template and template.offer_type == 'test_access' and template.test_duration_hours:
return template.test_duration_hours
return None
_TEMPLATE_ID_PATTERN = re.compile(r'promo_template_(?P<template_id>\d+)$')
def _extract_template_id_from_notification(notification_type: str | None) -> int | None:
if not notification_type:
return None
match = _TEMPLATE_ID_PATTERN.match(notification_type)
if not match:
return None
try:
return int(match.group('template_id'))
except (TypeError, ValueError):
return None
def _format_promo_offer_log_entry(
entry: PromoOfferLog,
index: int,
texts,
) -> str:
timestamp = entry.created_at.strftime('%d.%m.%Y %H:%M') if entry.created_at else '-'
action_key = ACTION_LABEL_KEYS.get(entry.action, '')
action_label = texts.get(action_key, entry.action.title())
lines = [f'{index}. <b>{timestamp}</b> — {action_label}']
user = entry.user
if user:
if user.username:
username = f'@{user.username}'
elif user.telegram_id:
username = f'ID{user.telegram_id}'
elif user.email:
username = f'📧{user.email}'
else:
username = f'User#{user.id}'
label = f'{username} (#{user.id})'
elif entry.user_id:
label = f'ID{entry.user_id}'
else:
label = texts.get('ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER', 'Неизвестный пользователь')
lines.append(texts.get('ADMIN_PROMO_OFFER_LOGS_USER', '👤 {user}').format(user=html.escape(label)))
if entry.percent:
lines.append(texts.get('ADMIN_PROMO_OFFER_LOGS_PERCENT', '📉 Скидка: {percent}%').format(percent=entry.percent))
effect_type = (entry.effect_type or '').lower()
if effect_type:
if effect_type == 'test_access':
effect_label = texts.get('ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST', '🧪 Тестовый доступ')
else:
effect_label = texts.get('ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT', '💸 Скидка')
lines.append(effect_label)
if entry.source:
lines.append(
texts.get('ADMIN_PROMO_OFFER_LOGS_SOURCE', '🏷 Источник: {source}').format(source=html.escape(entry.source))
)
details: dict[str, object] = entry.details if isinstance(entry.details, dict) else {}
reason_key = details.get('reason')
if reason_key:
reason_label = texts.get(REASON_LABEL_KEYS.get(reason_key, ''), '')
if not reason_label:
reason_label = texts.get(
'ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC',
'️ Действие: {reason}',
).format(reason=html.escape(str(reason_key)))
lines.append(reason_label)
description = details.get('description')
if description:
lines.append(
texts.get('ADMIN_PROMO_OFFER_LOGS_DESCRIPTION', '📝 {description}').format(
description=html.escape(str(description))
)
)
amount = details.get('amount_kopeks')
if isinstance(amount, int):
lines.append(
texts.get('ADMIN_PROMO_OFFER_LOGS_AMOUNT', '💰 Сумма: {amount}').format(amount=texts.format_price(amount))
)
squad_uuid = details.get('squad_uuid')
if squad_uuid:
lines.append(
texts.get('ADMIN_PROMO_OFFER_LOGS_SQUAD', '🌍 Сквад: {squad}').format(squad=html.escape(str(squad_uuid)))
)
new_squads = details.get('new_squads')
if isinstance(new_squads, (list, tuple)):
filtered = [html.escape(str(item)) for item in new_squads if item]
if filtered:
lines.append(
texts.get('ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS', '🌍 Новые сквады: {squads}').format(
squads=', '.join(filtered)
)
)
return '\n'.join(lines)
def _build_logs_keyboard(page: int, total_pages: int, language: str) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[InlineKeyboardButton] = []
if page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'promo_offer_logs_page_{page - 1}',
)
)
nav_row.append(
InlineKeyboardButton(
text=f'{page}/{total_pages}',
callback_data=f'promo_offer_logs_page_{page}',
)
)
if page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'promo_offer_logs_page_{page + 1}',
)
)
rows.append(nav_row)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_offers')])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _build_send_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup:
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
segments = config.get('allowed_segments', [])
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
for segment, label in segments:
rows.append(
[
InlineKeyboardButton(
text=label,
callback_data=f'promo_offer_send_{template.id}_{segment}',
)
]
)
rows.append(
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER',
'👤 Отправка пользователю',
),
callback_data=f'promo_offer_send_user_{template.id}_page_1',
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template.id}')])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _build_user_button_label(user: User) -> str:
status_emoji_map = {
UserStatus.ACTIVE.value: '',
UserStatus.BLOCKED.value: '🚫',
UserStatus.DELETED.value: '🗑️',
}
status_emoji = status_emoji_map.get(getattr(user, 'status', None), '')
subscription = getattr(user, 'subscription', None)
if subscription:
if subscription.is_trial:
subscription_emoji = '🎁'
elif subscription.is_active:
subscription_emoji = '💎'
else:
subscription_emoji = ''
else:
subscription_emoji = ''
name = (user.full_name or user.username or '').strip()
if not name:
if user.telegram_id:
name = f'ID {user.telegram_id}'
elif user.email:
name = user.email.split('@')[0][:15]
else:
name = f'User#{user.id}'
if len(name) > 20:
name = name[:17] + '...'
# Build identifier: telegram_id, email, or internal id
if user.telegram_id:
identifier = f'🆔 {user.telegram_id}'
elif user.email:
identifier = f'📧 {user.email[:20]}'
else:
identifier = f'#{user.id}'
parts = [status_emoji, subscription_emoji, name, identifier]
balance = getattr(user, 'balance_kopeks', 0)
if balance:
parts.append(f'💰 {settings.format_price(balance)}')
return ' '.join(parts)
async def _render_send_user_list(
*,
bot,
chat_id: int,
message_id: int,
template_id: int,
db_user: User,
db: AsyncSession,
state: FSMContext,
page: int = 1,
query: str | None = None,
) -> None:
user_service = UserService()
texts = get_texts(db_user.language)
limit = PROMO_OFFER_USER_PAGE_LIMIT
if query:
result = await user_service.search_users(db, query, page=page, limit=limit)
else:
result = await user_service.get_users_page(db, page=page, limit=limit)
total_pages = max(1, int(result.get('total_pages') or 1))
current_page = max(1, min(total_pages, int(result.get('current_page') or page or 1)))
if current_page != page:
if query:
result = await user_service.search_users(db, query, page=current_page, limit=limit)
else:
result = await user_service.get_users_page(db, page=current_page, limit=limit)
users: Sequence[User] = result.get('users', [])
lines = [
texts.t('ADMIN_PROMO_OFFER_SEND_USER_TITLE', '👤 <b>Отправка пользователю</b>'),
'',
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_HINT',
'Выберите пользователя для отправки промопредложения.',
),
]
if query:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY',
'🔍 Поиск: <b>{query}</b>',
).format(query=html.escape(query))
)
if not users:
lines.append('')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_EMPTY',
'Подходящие пользователи не найдены. Измените запрос поиска.',
)
)
keyboard_rows: list[list[InlineKeyboardButton]] = []
for user in users:
keyboard_rows.append(
[
InlineKeyboardButton(
text=_build_user_button_label(user),
callback_data=f'promo_offer_send_user_select_{template_id}_{user.id}',
)
]
)
if total_pages > 1:
nav_row: list[InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'promo_offer_send_user_{template_id}_page_{current_page - 1}',
)
)
nav_row.append(
InlineKeyboardButton(
text=f'{current_page}/{total_pages}',
callback_data=f'promo_offer_send_user_{template_id}_page_{current_page}',
)
)
if current_page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'promo_offer_send_user_{template_id}_page_{current_page + 1}',
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_SEND_USER_SEARCH', '🔍 Поиск'),
callback_data=f'promo_offer_send_user_search_{template_id}',
)
]
)
if query:
keyboard_rows.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_SEND_USER_RESET', '❌ Сбросить поиск'),
callback_data=f'promo_offer_send_user_reset_{template_id}',
)
]
)
keyboard_rows.append(
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS',
'↩️ К выбору категории',
),
callback_data=f'promo_offer_send_menu_{template_id}',
)
]
)
keyboard_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template_id}')])
markup = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
text = '\n'.join(lines)
current_message_id = message_id
try:
await bot.edit_message_text(
text,
chat_id=chat_id,
message_id=message_id,
reply_markup=markup,
parse_mode='HTML',
)
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if 'message is not modified' in error_text:
await bot.edit_message_reply_markup(
chat_id=chat_id,
message_id=message_id,
reply_markup=markup,
)
else:
sent_message = await bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=markup,
parse_mode='HTML',
)
current_message_id = sent_message.message_id
await state.update_data(
promo_offer_user_message={'chat_id': chat_id, 'message_id': current_message_id},
promo_offer_user_filter={
'template_id': template_id,
'page': current_page,
'query': query,
},
)
def _describe_offer(
template: PromoOfferTemplate,
language: str,
*,
server_name: str | None = None,
server_uuid: str | None = None,
) -> str:
texts = get_texts(language)
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
icon = config.get('icon', '📨')
lines = [f'{icon} <b>{html.escape(template.name)}</b>', '']
lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label))
lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours))
if template.offer_type != 'test_access':
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_DISCOUNT',
'Доп. скидка: {percent}% (суммируется с промогруппой)',
).format(percent=template.discount_percent)
)
stack_note = texts.t(
'ADMIN_PROMO_OFFER_STACKABLE_NOTE',
'Скидка применяется один раз и добавляется к промогруппе.',
)
if stack_note:
lines.append(stack_note)
active_hours = template.active_discount_hours or 0
if active_hours > 0:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_ACTIVE_DURATION',
'Скидка после активации действует {hours} ч.',
).format(hours=active_hours)
)
else:
duration = template.test_duration_hours or 0
lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_DURATION', 'Доступ: {hours} ч').format(hours=duration))
squads = template.test_squad_uuids or []
if server_name:
lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUAD_NAME', 'Сервер: {name}').format(name=server_name))
elif squads:
lines.append(
texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS', 'Сквады: {squads}').format(
squads=', '.join(str(item) for item in squads)
)
)
elif server_uuid:
lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS', 'Сквады: {squads}').format(squads=server_uuid))
else:
lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY', 'Сквады: не указаны'))
allowed_segments = config.get('allowed_segments', [])
if allowed_segments:
segment_labels = [label for _, label in allowed_segments]
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_ALLOWED', 'Доступные категории:'))
lines.extend(segment_labels)
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_PREVIEW', 'Предпросмотр:'))
lines.append(
_render_template_text(
template,
language,
server_name=server_name,
)
)
return '\n'.join(lines)
@admin_required
@error_handler
async def show_promo_offers_menu(callback: CallbackQuery, db_user: User, db: AsyncSession):
await ensure_default_templates(db, created_by=db_user.id)
templates = await list_promo_offer_templates(db)
texts = get_texts(db_user.language)
header = texts.t('ADMIN_PROMO_OFFERS_TITLE', '🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:')
await callback.message.edit_text(
header,
reply_markup=_build_templates_keyboard(templates, db_user.language),
parse_mode='HTML',
)
await callback.answer()
@admin_required
@error_handler
async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
template_id = int(callback.data.split('_')[-1])
except (ValueError, AttributeError):
await callback.answer('❌ Неверный идентификатор', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await state.update_data(selected_promo_offer=template.id)
squad_uuid, squad_name = await _resolve_template_squad(db, template)
description = _describe_offer(
template,
db_user.language,
server_name=squad_name,
server_uuid=squad_uuid,
)
await callback.message.edit_text(
description,
reply_markup=_build_offer_detail_keyboard(template, db_user.language),
parse_mode='HTML',
)
await callback.answer()
@admin_required
@error_handler
async def show_promo_offer_logs(callback: CallbackQuery, db_user: User, db: AsyncSession):
try:
if '_page_' in callback.data:
page = int(callback.data.split('_page_')[-1])
else:
page = 1
except (ValueError, AttributeError):
page = 1
page = max(page, 1)
limit = PROMO_OFFER_LOGS_PAGE_LIMIT
offset = (page - 1) * limit
logs, total = await list_promo_offer_logs(db, offset=offset, limit=limit)
total_pages = max(1, (total + limit - 1) // limit)
if page > total_pages and total > 0:
page = total_pages
offset = (page - 1) * limit
logs, _ = await list_promo_offer_logs(db, offset=offset, limit=limit)
texts = get_texts(db_user.language)
header = texts.t(
'ADMIN_PROMO_OFFER_LOGS_TITLE',
'📜 <b>Лог операций промо-предложений</b>',
)
if logs:
message_lines = [
header,
texts.get(
'ADMIN_PROMO_OFFER_LOGS_PAGINATION',
'Страница {page}/{total}',
).format(page=page, total=total_pages),
'',
]
for index, entry in enumerate(logs, start=offset + 1):
message_lines.append(_format_promo_offer_log_entry(entry, index, texts))
message_lines.append('')
message_text = '\n'.join(message_lines).rstrip()
else:
message_text = '\n'.join(
[
header,
'',
texts.get(
'ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY',
'Записей пока нет.',
),
]
)
keyboard = _build_logs_keyboard(page, total_pages, db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
await callback.answer()
async def _prompt_edit(callback: CallbackQuery, state: FSMContext, template_id: int, prompt: str, new_state):
await state.update_data(
selected_promo_offer=template_id,
promo_edit_message_id=callback.message.message_id,
promo_edit_chat_id=callback.message.chat.id,
)
await callback.message.edit_text(prompt)
await state.set_state(new_state)
await callback.answer()
@admin_required
@error_handler
async def prompt_edit_message(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_MESSAGE', 'Введите новый текст предложения:')
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_message)
@admin_required
@error_handler
async def prompt_edit_button(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_BUTTON', 'Введите новый текст кнопки:')
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_button)
@admin_required
@error_handler
async def prompt_edit_valid(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_VALID', 'Укажите срок действия (в часах):')
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_valid_hours)
@admin_required
@error_handler
async def prompt_edit_discount(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_DISCOUNT', 'Введите размер скидки в процентах:')
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_discount)
@admin_required
@error_handler
async def prompt_edit_active_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t(
'ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION',
'Введите срок действия активированной скидки (в часах):',
)
await _prompt_edit(
callback,
state,
template_id,
prompt,
AdminStates.editing_promo_offer_active_duration,
)
@admin_required
@error_handler
async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
texts = get_texts(db_user.language)
prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_DURATION', 'Введите длительность тестового доступа (в часах):')
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_test_duration)
@admin_required
@error_handler
async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split('_')[-1])
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await state.update_data(
selected_promo_offer=template.id,
promo_edit_message_id=callback.message.message_id,
promo_edit_chat_id=callback.message.chat.id,
)
await _render_squad_selection(callback, template, db, db_user.language)
await callback.answer()
async def _render_squad_selection(
callback: CallbackQuery,
template: PromoOfferTemplate,
db: AsyncSession,
language: str,
page: int = 1,
):
texts = get_texts(language)
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=SQUADS_PAGE_LIMIT,
)
if total_count == 0:
await callback.message.edit_text(
texts.t('ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE', '❌ Доступные серверы не найдены.'),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_squad_back_{template.id}')]
]
),
)
return
selected_uuid = None
if template.test_squad_uuids:
selected_uuid = str(template.test_squad_uuids[0])
selected_server_name = None
if selected_uuid:
selected_server = next((srv for srv in squads if srv.squad_uuid == selected_uuid), None)
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
if selected_server:
selected_server_name = html.escape(selected_server.display_name)
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
if selected_server_name:
current = texts.t(
'ADMIN_PROMO_OFFER_SELECTED_SQUAD',
'Текущий сквад: {name}',
).format(name=selected_server_name)
elif selected_uuid:
current = texts.t(
'ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID',
'Текущий сквад: {uuid}',
).format(uuid=selected_uuid)
else:
current = texts.t(
'ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY',
'Текущий сквад: не выбран',
)
hint = texts.t(
'ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT',
'Выберите сервер для тестового доступа из списка ниже.',
)
total_pages = (total_count + SQUADS_PAGE_LIMIT - 1) // SQUADS_PAGE_LIMIT or 1
page = max(1, min(page, total_pages))
lines = [header, '', current, '', hint]
if total_pages > 1:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE',
'Страница {page}/{total}',
).format(page=page, total=total_pages)
)
text = '\n'.join(lines)
keyboard_rows: list[list[InlineKeyboardButton]] = []
for server in squads:
emoji = '' if server.squad_uuid == selected_uuid else ('' if server.is_available else '🔒')
label = f'{emoji} {server.display_name}'
keyboard_rows.append(
[
InlineKeyboardButton(
text=label,
callback_data=f'promo_offer_select_squad_{template.id}_{server.id}_{page}',
)
]
)
if total_pages > 1:
nav_row: list[InlineKeyboardButton] = []
if page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'promo_offer_squad_page_{template.id}_{page - 1}',
)
)
if page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'promo_offer_squad_page_{template.id}_{page + 1}',
)
)
if nav_row:
keyboard_rows.append(nav_row)
action_row = [
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR', '🗑 Очистить'),
callback_data=f'promo_offer_clear_squad_{template.id}_{page}',
),
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK', '↩️ Назад'),
callback_data=f'promo_offer_squad_back_{template.id}',
),
]
keyboard_rows.append(action_row)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode='HTML',
)
async def _render_offer_details(
callback: CallbackQuery,
template: PromoOfferTemplate,
language: str,
db: AsyncSession,
):
squad_uuid, squad_name = await _resolve_template_squad(db, template)
description = _describe_offer(
template,
language,
server_name=squad_name,
server_uuid=squad_uuid,
)
await callback.message.edit_text(
description,
reply_markup=_build_offer_detail_keyboard(template, language),
parse_mode='HTML',
)
async def _handle_edit_field(
message: Message,
state: FSMContext,
db: AsyncSession,
db_user: User,
field: str,
):
data = await state.get_data()
template_id = data.get('selected_promo_offer')
if not template_id:
await _safe_delete_message(message)
await message.answer('❌ Не удалось определить предложение. Повторите действие.')
await state.clear()
return
template = await get_promo_offer_template_by_id(db, int(template_id))
if not template:
await _safe_delete_message(message)
await message.answer('❌ Предложение не найдено.')
await state.clear()
return
value = message.text.strip()
try:
if field == 'message_text':
await update_promo_offer_template(db, template, message_text=value)
elif field == 'button_text':
await update_promo_offer_template(db, template, button_text=value)
elif field == 'valid_hours':
hours = max(1, int(value))
await update_promo_offer_template(db, template, valid_hours=hours)
elif field == 'discount_percent':
percent = max(0, min(100, int(value)))
await update_promo_offer_template(db, template, discount_percent=percent)
elif field == 'active_discount_hours':
hours = max(1, int(value))
await update_promo_offer_template(db, template, active_discount_hours=hours)
elif field == 'test_duration_hours':
hours = max(1, int(value))
await update_promo_offer_template(db, template, test_duration_hours=hours)
elif field == 'test_squad_uuids':
if value.lower() in {'clear', 'очистить'}:
squads: list[str] = []
else:
squads = [item for item in re.split(r'[\s,]+', value) if item]
await update_promo_offer_template(db, template, test_squad_uuids=squads)
else:
raise ValueError('Unsupported field')
except ValueError:
await _safe_delete_message(message)
await message.answer('❌ Некорректное значение. Попробуйте снова.')
return
edit_message_id = data.get('promo_edit_message_id')
edit_chat_id = data.get('promo_edit_chat_id', message.chat.id)
await state.clear()
updated_template = await get_promo_offer_template_by_id(db, template.id)
if not updated_template:
await _safe_delete_message(message)
await message.answer('❌ Предложение не найдено после обновления.')
return
squad_uuid, squad_name = await _resolve_template_squad(db, updated_template)
description = _describe_offer(
updated_template,
db_user.language,
server_name=squad_name,
server_uuid=squad_uuid,
)
reply_markup = _build_offer_detail_keyboard(updated_template, db_user.language)
if edit_message_id:
try:
await message.bot.edit_message_text(
description,
chat_id=edit_chat_id,
message_id=edit_message_id,
reply_markup=reply_markup,
parse_mode='HTML',
)
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if 'there is no text in the message to edit' in error_text:
logger.debug('Сообщение промо без текста, пересылаем обновлённую версию')
try:
await message.bot.delete_message(chat_id=edit_chat_id, message_id=edit_message_id)
except TelegramBadRequest:
logger.debug('Не удалось удалить сообщение промо без текста')
else:
logger.warning('Не удалось обновить сообщение редактирования промо', exc=exc)
await message.answer(description, reply_markup=reply_markup, parse_mode='HTML')
else:
await message.answer(description, reply_markup=reply_markup, parse_mode='HTML')
await _safe_delete_message(message)
@admin_required
@error_handler
async def show_send_segments(callback: CallbackQuery, db_user: User, db: AsyncSession):
template_id = int(callback.data.split('_')[-1])
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await callback.message.edit_reply_markup(reply_markup=_build_send_keyboard(template, db_user.language))
await callback.answer()
@admin_required
@error_handler
async def show_send_user_list(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
prefix = 'promo_offer_send_user_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, page_label, page_str = payload.split('_', 2)
if page_label != 'page':
raise ValueError('invalid payload')
template_id = int(template_id_str)
page = int(page_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
page = max(page, 1)
await state.set_state(AdminStates.selecting_promo_offer_user)
data = await state.get_data()
filter_data = data.get('promo_offer_user_filter') or {}
query = filter_data.get('query') if filter_data.get('template_id') == template_id else None
await _render_send_user_list(
bot=callback.bot,
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
template_id=template_id,
db_user=db_user,
db=db,
state=state,
page=page,
query=query,
)
await callback.answer()
@admin_required
@error_handler
async def prompt_send_user_search(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
template_id = int(callback.data.split('_')[-1])
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await _clear_promo_offer_search_prompt(state, callback.bot)
await state.set_state(AdminStates.searching_promo_offer_user)
await state.update_data(promo_offer_user_search_template=template_id)
texts = get_texts(db_user.language)
prompt_message = await callback.message.answer(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT',
'Введите имя, username или ID пользователя для поиска:',
)
)
await state.update_data(
promo_offer_user_search_prompt={
'chat_id': prompt_message.chat.id,
'message_id': prompt_message.message_id,
}
)
await callback.answer()
@admin_required
@error_handler
async def reset_send_user_search(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
template_id = int(callback.data.split('_')[-1])
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await _clear_promo_offer_search_prompt(state, callback.bot)
await state.set_state(AdminStates.selecting_promo_offer_user)
await _render_send_user_list(
bot=callback.bot,
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
template_id=template_id,
db_user=db_user,
db=db,
state=state,
page=1,
query=None,
)
await callback.answer()
@admin_required
@error_handler
async def back_to_user_list(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
template_id = int(callback.data.split('_')[-1])
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await _clear_promo_offer_search_prompt(state, callback.bot)
data = await state.get_data()
filter_data = data.get('promo_offer_user_filter') or {}
if filter_data.get('template_id') == template_id:
page = int(filter_data.get('page') or 1)
query = filter_data.get('query')
else:
page = 1
query = None
await state.set_state(AdminStates.selecting_promo_offer_user)
await _render_send_user_list(
bot=callback.bot,
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
template_id=template_id,
db_user=db_user,
db=db,
state=state,
page=page,
query=query,
)
await callback.answer()
@admin_required
@error_handler
async def process_send_user_search(
message: Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
query = (message.text or '').strip()
if not query:
await message.answer('❌ Введите корректный запрос для поиска')
return
data = await state.get_data()
template_id = data.get('promo_offer_user_search_template')
if not template_id:
await message.answer('❌ Не удалось определить промопредложение')
await _safe_delete_message(message)
return
try:
template_id = int(template_id)
except (TypeError, ValueError):
await message.answer('❌ Некорректные данные поиска')
await _safe_delete_message(message)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await message.answer('❌ Предложение не найдено')
await _safe_delete_message(message)
return
await _clear_promo_offer_search_prompt(state, message.bot)
message_info = data.get('promo_offer_user_message') or {}
chat_id = message_info.get('chat_id')
message_id = message_info.get('message_id')
if not chat_id or not message_id:
placeholder = await message.answer('⏳ Обновляем список пользователей...')
chat_id = placeholder.chat.id
message_id = placeholder.message_id
await _render_send_user_list(
bot=message.bot,
chat_id=chat_id,
message_id=message_id,
template_id=template_id,
db_user=db_user,
db=db,
state=state,
page=1,
query=query,
)
await state.set_state(AdminStates.selecting_promo_offer_user)
await state.update_data(
promo_offer_user_search_template=None,
promo_offer_user_search_prompt=None,
)
await _safe_delete_message(message)
@admin_required
@error_handler
async def show_selected_user_details(
callback: CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
try:
prefix = 'promo_offer_send_user_select_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, user_id_str = payload.split('_', 1)
template_id = int(template_id_str)
user_id = int(user_id_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer('❌ Пользователь не найден', show_alert=True)
return
texts = get_texts(db_user.language)
status_map = {
UserStatus.ACTIVE.value: texts.ADMIN_USER_STATUS_ACTIVE,
UserStatus.BLOCKED.value: texts.ADMIN_USER_STATUS_BLOCKED,
UserStatus.DELETED.value: texts.ADMIN_USER_STATUS_DELETED,
}
name = html.escape(user.full_name or user.username or str(user.telegram_id or user.id))
username = html.escape(user.username) if user.username else None
balance = getattr(user, 'balance_kopeks', 0)
lines = [
texts.t('ADMIN_PROMO_OFFER_SEND_USER_PROFILE', '👤 <b>{name}</b>').format(name=name),
texts.t('ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM', '🆔 <code>{telegram_id}</code>').format(
telegram_id=user.telegram_id or ''
),
]
if username:
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_USERNAME', '🔗 @{username}').format(username=username))
status_label = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN)
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_STATUS', 'Статус: {status}').format(status=status_label))
if balance:
lines.append(
texts.t('ADMIN_PROMO_OFFER_SEND_USER_BALANCE', 'Баланс: {amount}').format(
amount=settings.format_price(balance)
)
)
subscription = getattr(user, 'subscription', None)
subscriptions_list = getattr(user, 'subscriptions', None) or []
if settings.is_multi_tariff_enabled() and subscriptions_list:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписки</b>'))
for sub in subscriptions_list:
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
lines.append(f'<b>{tariff_name}</b>')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS',
'Статус: {status}',
).format(status=sub.status_display)
)
end_date_text = (
format_datetime(sub.end_date)
if sub.end_date
else texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN',
'не указано',
)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END',
'Истекает: {date}',
).format(date=end_date_text)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC',
'Трафик: {used}/{limit} ГБ',
).format(
used=sub.traffic_used_gb or 0,
limit=sub.traffic_limit_gb or 0,
)
)
connected = sub.connected_squads or []
if connected:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS',
'Подключено сквадов: {count}',
).format(count=len(connected))
)
elif subscription:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписка</b>'))
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS',
'Статус: {status}',
).format(status=subscription.status_display)
)
end_date_text = (
format_datetime(subscription.end_date)
if subscription.end_date
else texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN',
'не указано',
)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END',
'Истекает: {date}',
).format(date=end_date_text)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC',
'Трафик: {used}/{limit} ГБ',
).format(
used=subscription.traffic_used_gb or 0,
limit=subscription.traffic_limit_gb or 0,
)
)
connected = subscription.connected_squads or []
if connected:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS',
'Подключено сквадов: {count}',
).format(count=len(connected))
)
else:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION', '💳 Подписка отсутствует'))
now = datetime.now(UTC)
percent = 0
try:
percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0)
except (TypeError, ValueError):
percent = 0
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
if percent > 0 and (not expires_at or expires_at > now):
discount_line = texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT',
'💸 Активная скидка: {percent}%',
).format(percent=percent)
if expires_at:
date_text = format_datetime(expires_at)
remaining_seconds = int((expires_at - now).total_seconds())
if remaining_seconds > 0:
discount_line += texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT',
' (до {date}, осталось {time})',
).format(date=date_text, time=format_duration(remaining_seconds))
else:
discount_line += texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL',
' (до {date})',
).format(date=date_text)
source = getattr(user, 'promo_offer_discount_source', None)
if source:
discount_line += texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE',
' — источник: {source}',
).format(source=html.escape(str(source)))
else:
discount_line = texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE',
'💸 Активная скидка отсутствует',
)
lines.append('')
lines.append(discount_line)
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
offer_label = texts.t(
config.get('label_key', ''),
config.get('default_label', template.offer_type),
)
lines.append('')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER',
'📨 <b>Выбранное предложение</b>',
)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE',
'Тип: {label}',
).format(label=offer_label)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID',
'Действует: {hours} ч.',
).format(hours=template.valid_hours)
)
if template.offer_type == 'test_access':
duration_hours = template.test_duration_hours or 0
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION',
'Тестовый доступ: {hours} ч.',
).format(hours=duration_hours)
)
else:
if template.discount_percent:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT',
'Скидка: {percent}%',
).format(percent=template.discount_percent)
)
active_hours = template.active_discount_hours or 0
if active_hours:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION',
'После активации действует {hours} ч.',
).format(hours=active_hours)
)
active_offers = await list_discount_offers(db, user_id=user.id, is_active=True)
if active_offers:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS', '📨 Активные предложения:'))
template_map: dict[int, PromoOfferTemplate] = {template.id: template}
template_ids_to_load: set[int] = set()
for offer in active_offers:
offer_template_id = _extract_template_id_from_notification(offer.notification_type)
if offer_template_id and offer_template_id not in template_map:
template_ids_to_load.add(offer_template_id)
if template_ids_to_load:
templates_result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id.in_(template_ids_to_load))
)
for offer_template in templates_result.scalars():
template_map[offer_template.id] = offer_template
for offer in active_offers[:5]:
parts: list[str] = []
if offer.effect_type == 'test_access':
parts.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST', 'Тестовый доступ'))
if offer.discount_percent:
parts.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT',
'Скидка {percent}%',
).format(percent=offer.discount_percent)
)
if offer.bonus_amount_kopeks:
parts.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS',
'Бонус {amount}',
).format(amount=settings.format_price(offer.bonus_amount_kopeks))
)
description = ', '.join(parts) or offer.effect_type
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER',
'{description}',
).format(description=description)
)
expires_text = (
format_datetime(offer.expires_at)
if offer.expires_at
else texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока')
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES',
' Истекает: {expires}',
).format(expires=expires_text)
)
status_label = texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED',
'✅ Принято',
)
if not offer.claimed_at:
status_label = texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING',
'⏳ Не принято',
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS',
' Статус: {status}',
).format(status=status_label)
)
time_left = _format_offer_remaining(offer, texts)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT',
' Осталось: {time}',
).format(time=time_left)
)
offer_template = None
offer_template_id = _extract_template_id_from_notification(offer.notification_type)
if offer_template_id:
offer_template = template_map.get(offer_template_id)
active_hours = _extract_offer_active_hours(offer, offer_template)
if active_hours:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION',
' После активации: {duration}',
).format(duration=format_duration(active_hours * 3600))
)
if offer.expires_at and offer.created_at:
total_seconds = int((offer.expires_at - offer.created_at).total_seconds())
if total_seconds > 0:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION',
' Всего действует: {duration}',
).format(duration=format_duration(total_seconds))
)
lines.append('')
if lines[-1] == '':
lines.pop()
else:
lines.append('')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS',
'📨 Активных предложений нет',
)
)
stats_stmt = select(
func.count(DiscountOffer.id),
func.sum(
case(
(DiscountOffer.claimed_at.isnot(None), 1),
else_=0,
)
),
).where(
DiscountOffer.user_id == user.id,
DiscountOffer.notification_type == f'promo_template_{template.id}',
)
stats_result = await db.execute(stats_stmt)
total_offers, accepted_offers = stats_result.one()
total_offers = int(total_offers or 0)
accepted_offers = int(accepted_offers or 0)
pending_offers = max(total_offers - accepted_offers, 0)
if total_offers > 0:
lines.append('')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER',
'📊 Статистика предложений',
)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL',
'Всего отправлено: {count}',
).format(count=total_offers)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED',
'Принято: {count}',
).format(count=accepted_offers)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING',
'Не принято: {count}',
).format(count=pending_offers)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE',
'Активно сейчас: {count}',
).format(count=len(active_offers))
)
if settings.is_multi_tariff_enabled() and subscriptions_list:
now = datetime.now(UTC)
sub_ids = [sub.id for sub in subscriptions_list]
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
.where(
SubscriptionTemporaryAccess.subscription_id.in_(sub_ids),
SubscriptionTemporaryAccess.is_active == True,
SubscriptionTemporaryAccess.expires_at > now,
)
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
)
accesses = result.scalars().all()
elif subscription:
now = datetime.now(UTC)
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
.where(
SubscriptionTemporaryAccess.subscription_id == subscription.id,
SubscriptionTemporaryAccess.is_active == True,
SubscriptionTemporaryAccess.expires_at > now,
)
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
)
accesses = result.scalars().all()
else:
accesses = []
if accesses:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS', '🧪 Активные тестовые доступы:'))
for entry in accesses[:5]:
squad_label = html.escape(entry.squad_uuid or '')
expires_text = (
format_datetime(entry.expires_at)
if entry.expires_at
else texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока')
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM',
'{squad} (до {expires})',
).format(squad=squad_label, expires=expires_text)
)
keyboard_rows = [
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON',
'📬 Отправить предложение',
),
callback_data=f'promo_offer_send_user_confirm_{template_id}_{user.id}',
)
],
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST',
'⬅️ К списку пользователей',
),
callback_data=f'promo_offer_send_user_back_{template_id}',
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template_id}')],
]
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode='HTML',
)
await state.set_state(AdminStates.selecting_promo_offer_user)
await state.update_data(
promo_offer_selected_user=user.id,
promo_offer_user_message={
'chat_id': callback.message.chat.id,
'message_id': callback.message.message_id,
},
)
await callback.answer()
def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]:
if settings.is_multi_tariff_enabled():
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.subscription_url), None)
else:
subscription = getattr(user, 'subscription', None)
if not subscription:
return []
button_text = texts.t('CONNECT_BUTTON', '🔗 Подключиться')
subscription_link = get_display_subscription_link(subscription)
connect_mode = settings.CONNECT_BUTTON_MODE
def _fallback_button() -> InlineKeyboardButton:
return InlineKeyboardButton(text=button_text, callback_data='subscription_connect')
rows: list[list[InlineKeyboardButton]] = []
if connect_mode == 'miniapp_subscription':
if subscription_link:
rows.append(
[
InlineKeyboardButton(
text=button_text,
web_app=types.WebAppInfo(url=subscription_link),
)
]
)
else:
rows.append([_fallback_button()])
elif connect_mode == 'miniapp_custom':
if settings.MINIAPP_CUSTOM_URL:
rows.append(
[
InlineKeyboardButton(
text=button_text,
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
]
)
else:
rows.append([_fallback_button()])
elif connect_mode == 'link':
if subscription_link:
rows.append([InlineKeyboardButton(text=button_text, url=subscription_link)])
else:
rows.append([_fallback_button()])
elif connect_mode == 'happ_cryptolink':
if subscription_link:
rows.append([InlineKeyboardButton(text=button_text, callback_data='open_subscription_link')])
else:
rows.append([_fallback_button()])
else:
rows.append([_fallback_button()])
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
return rows
async def _send_offer_to_users(
bot,
template: PromoOfferTemplate,
db_user: User,
db: AsyncSession,
users: Sequence[User],
*,
squad_name: str | None,
effect_type: str,
) -> tuple[int, int]:
from app.database.database import AsyncSessionLocal
sent = 0
failed = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_offer(user):
"""Отправляет одно предложение с семафором ограничения"""
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Пропуск email-пользователя при рассылке промо', user_id=user.id)
return False
async with semaphore:
try:
# Используем отдельную сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
if settings.is_multi_tariff_enabled():
_user_subs = getattr(user, 'subscriptions', None) or []
_active_subs = [s for s in _user_subs if s.is_active]
if _active_subs:
_non_daily = [s for s in _active_subs if not getattr(s, 'is_daily_tariff', False)]
_eligible = _non_daily or _active_subs
_best = max(_eligible, key=lambda s: s.days_left)
_offer_sub_id = _best.id
elif _user_subs:
_offer_sub_id = _user_subs[0].id
else:
_offer_sub_id = None
else:
_offer_sub = getattr(user, 'subscription', None)
_offer_sub_id = _offer_sub.id if _offer_sub else None
offer_record = await upsert_discount_offer(
new_db,
user_id=user.id,
subscription_id=_offer_sub_id,
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,
},
)
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f'claim_discount_{offer_record.id}',
)
]
]
keyboard_rows.append(
[
InlineKeyboardButton(
text=user_texts.t('PROMO_OFFER_CLOSE', '❌ Закрыть'),
callback_data='promo_offer_close',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='HTML',
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
'Не удалось отправить предложение пользователю', telegram_id=user.telegram_id or user.id, exc=exc
)
return False
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
'Ошибка рассылки промо предложения пользователю', telegram_id=user.telegram_id or user.id, exc=exc
)
return False
# Отправляем предложения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i : i + batch_size]
tasks = [send_single_offer(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool): # Успешно или неуспешно
if result:
sent += 1
else:
failed += 1
elif isinstance(result, Exception): # Ошибка выполнения задачи
failed += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent, failed
@admin_required
@error_handler
async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: AsyncSession):
try:
prefix = 'promo_offer_send_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
data = callback.data[len(prefix) :]
template_id_str, segment = data.split('_', 1)
template_id = int(template_id_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
squad_uuid, squad_name = await _resolve_template_squad(db, template)
allowed_segments = {seg for seg, _ in config.get('allowed_segments', [])}
if segment not in allowed_segments:
await callback.answer('⚠️ Нельзя отправить это предложение выбранной категории', show_alert=True)
return
texts = get_texts(db_user.language)
await callback.answer(texts.t('ADMIN_PROMO_OFFER_SENDING', 'Начинаем рассылку...'), show_alert=True)
users = await get_users_for_promo_segment(db, segment)
initial_count = len(users)
if template.offer_type == 'test_access' and squad_uuid:
filtered_users: list[User] = []
for user in users:
if settings.is_multi_tariff_enabled():
all_squads: set[str] = set()
for s in getattr(user, 'subscriptions', None) or []:
all_squads.update(s.connected_squads or [])
connected = all_squads
else:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if squad_uuid in connected:
continue
filtered_users.append(user)
users = filtered_users
if not users:
await callback.message.answer(texts.t('ADMIN_PROMO_OFFER_NO_USERS', 'Подходящих пользователей не найдено.'))
return
skipped = initial_count - len(users)
effect_type = config.get('effect_type', 'percent_discount')
sent, failed = await _send_offer_to_users(
callback.bot,
template,
db_user,
db,
users,
squad_name=squad_name,
effect_type=effect_type,
)
summary = texts.t(
'ADMIN_PROMO_OFFER_RESULT',
'📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}',
).format(sent=sent, failed=failed)
if skipped > 0:
summary += '\n' + texts.t(
'ADMIN_PROMO_OFFER_SKIPPED',
'Пропущено: {skipped} (уже есть доступ)',
).format(skipped=skipped)
refreshed = await get_promo_offer_template_by_id(db, template.id)
result_keyboard_rows: list[list[InlineKeyboardButton]] = []
if refreshed:
result_keyboard_rows.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE', '↩️ К предложению'),
callback_data=f'promo_offer_{refreshed.id}',
)
]
)
result_keyboard_rows.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_LIST', '⬅️ К промопредложениям'),
callback_data='admin_promo_offers',
)
]
)
await callback.message.edit_text(
summary,
reply_markup=InlineKeyboardMarkup(inline_keyboard=result_keyboard_rows),
parse_mode='HTML',
)
@admin_required
@error_handler
async def send_offer_to_user(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
prefix = 'promo_offer_send_user_confirm_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, user_id_str = payload.split('_', 1)
template_id = int(template_id_str)
user_id = int(user_id_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer('❌ Пользователь не найден', show_alert=True)
return
config = OFFER_TYPE_CONFIG.get(template.offer_type, {})
squad_uuid, squad_name = await _resolve_template_squad(db, template)
effect_type = config.get('effect_type', 'percent_discount')
texts = get_texts(db_user.language)
await callback.answer(texts.t('ADMIN_PROMO_OFFER_SENDING', 'Начинаем рассылку...'), show_alert=True)
users_to_send: list[User] = [user]
skipped = 0
if template.offer_type == 'test_access' and squad_uuid:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if squad_uuid in connected:
users_to_send = []
skipped = 1
sent = 0
failed = 0
if users_to_send:
sent, failed = await _send_offer_to_users(
callback.bot,
template,
db_user,
db,
users_to_send,
squad_name=squad_name,
effect_type=effect_type,
)
display_name = html.escape(user.full_name or user.username or str(user.telegram_id or user.id))
summary_lines = [
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE',
'📬 Отправка пользователю {name}',
).format(name=display_name),
texts.t(
'ADMIN_PROMO_OFFER_RESULT',
'📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}',
).format(sent=sent, failed=failed),
]
if skipped:
summary_lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SKIPPED',
'Пропущено: {skipped} (уже есть доступ)',
).format(skipped=skipped)
)
if not users_to_send and not skipped:
summary_lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT',
'Отправка не выполнена',
)
)
keyboard_rows = [
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE',
'👤 К профилю пользователя',
),
callback_data=f'promo_offer_send_user_select_{template.id}_{user.id}',
)
],
[
InlineKeyboardButton(
text=texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST',
'⬅️ К списку пользователей',
),
callback_data=f'promo_offer_send_user_back_{template.id}',
)
],
[
InlineKeyboardButton(
text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE', '↩️ К предложению'),
callback_data=f'promo_offer_{template.id}',
)
],
]
await callback.message.edit_text(
'\n'.join(summary_lines),
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode='HTML',
)
await state.set_state(AdminStates.selecting_promo_offer_user)
await state.update_data(
promo_offer_selected_user=user.id,
promo_offer_user_message={
'chat_id': callback.message.chat.id,
'message_id': callback.message.message_id,
},
)
async def process_edit_message_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'message_text')
async def process_edit_button_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'button_text')
async def process_edit_valid_hours(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'valid_hours')
async def process_edit_active_duration_hours(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'active_discount_hours')
async def process_edit_discount_percent(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'discount_percent')
async def process_edit_test_duration(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, 'test_duration_hours')
@admin_required
@error_handler
async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
prefix = 'promo_offer_squad_page_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, page_str = payload.split('_', 1)
template_id = int(template_id_str)
page = int(page_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await state.update_data(selected_promo_offer=template.id)
await _render_squad_selection(callback, template, db, db_user.language, page=page)
await callback.answer()
@admin_required
@error_handler
async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
prefix = 'promo_offer_select_squad_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, server_id_str, page_str = payload.split('_', 2)
template_id = int(template_id_str)
server_id = int(server_id_str)
page = int(page_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer(
get_texts(db_user.language).t(
'ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND',
'❌ Сервер не найден',
),
show_alert=True,
)
return
await update_promo_offer_template(db, template, test_squad_uuids=[server.squad_uuid])
updated = await get_promo_offer_template_by_id(db, template.id)
if updated:
await state.update_data(selected_promo_offer=updated.id)
texts = get_texts(db_user.language)
await callback.answer(texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED', '✅ Сквад обновлён'))
if updated:
await _render_offer_details(callback, updated, db_user.language, db)
else:
await _render_squad_selection(callback, template, db, db_user.language, page=page)
@admin_required
@error_handler
async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
prefix = 'promo_offer_clear_squad_'
if not callback.data.startswith(prefix):
raise ValueError('invalid prefix')
payload = callback.data[len(prefix) :]
template_id_str, page_str = payload.split('_', 1)
template_id = int(template_id_str)
page = int(page_str)
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await update_promo_offer_template(db, template, test_squad_uuids=[])
updated = await get_promo_offer_template_by_id(db, template.id)
if updated:
await state.update_data(selected_promo_offer=updated.id)
texts = get_texts(db_user.language)
await callback.answer(texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED', '✅ Сквад очищен'))
if updated:
await _render_squad_selection(callback, updated, db, db_user.language, page=page)
else:
await _render_squad_selection(callback, template, db, db_user.language, page=page)
@admin_required
@error_handler
async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
try:
template_id = int(callback.data.split('_')[-1])
except (ValueError, AttributeError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
await callback.answer('❌ Предложение не найдено', show_alert=True)
return
await state.update_data(selected_promo_offer=template.id)
await _render_offer_details(callback, template, db_user.language, db)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_offers_menu, F.data == 'admin_promo_offers')
dp.callback_query.register(prompt_edit_message, F.data.startswith('promo_offer_edit_message_'))
dp.callback_query.register(prompt_edit_button, F.data.startswith('promo_offer_edit_button_'))
dp.callback_query.register(prompt_edit_valid, F.data.startswith('promo_offer_edit_valid_'))
dp.callback_query.register(prompt_edit_discount, F.data.startswith('promo_offer_edit_discount_'))
dp.callback_query.register(prompt_edit_active_duration, F.data.startswith('promo_offer_edit_active_'))
dp.callback_query.register(prompt_edit_duration, F.data.startswith('promo_offer_edit_duration_'))
dp.callback_query.register(prompt_edit_squads, F.data.startswith('promo_offer_edit_squads_'))
dp.callback_query.register(paginate_squad_selection, F.data.startswith('promo_offer_squad_page_'))
dp.callback_query.register(select_squad_for_template, F.data.startswith('promo_offer_select_squad_'))
dp.callback_query.register(clear_squad_for_template, F.data.startswith('promo_offer_clear_squad_'))
dp.callback_query.register(back_to_offer_from_squads, F.data.startswith('promo_offer_squad_back_'))
dp.callback_query.register(show_send_user_list, F.data.regexp(r'^promo_offer_send_user_\d+_page_\d+$'))
dp.callback_query.register(show_selected_user_details, F.data.startswith('promo_offer_send_user_select_'))
dp.callback_query.register(prompt_send_user_search, F.data.startswith('promo_offer_send_user_search_'))
dp.callback_query.register(reset_send_user_search, F.data.startswith('promo_offer_send_user_reset_'))
dp.callback_query.register(back_to_user_list, F.data.startswith('promo_offer_send_user_back_'))
dp.callback_query.register(show_send_segments, F.data.startswith('promo_offer_send_menu_'))
dp.callback_query.register(send_offer_to_user, F.data.startswith('promo_offer_send_user_confirm_'))
dp.callback_query.register(send_offer_to_segment, F.data.startswith('promo_offer_send_'))
dp.callback_query.register(show_promo_offer_logs, F.data.regexp(r'^promo_offer_logs_page_\d+$'))
dp.callback_query.register(show_promo_offer_details, F.data.startswith('promo_offer_'))
dp.message.register(process_edit_message_text, AdminStates.editing_promo_offer_message)
dp.message.register(process_edit_button_text, AdminStates.editing_promo_offer_button)
dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours)
dp.message.register(process_edit_active_duration_hours, AdminStates.editing_promo_offer_active_duration)
dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount)
dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration)
dp.message.register(process_send_user_search, AdminStates.searching_promo_offer_user)