diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 030060a9..bb3ac0b4 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -3,19 +3,16 @@ from __future__ import annotations import html import logging import re -from datetime import datetime from typing import Dict, List, Optional, Sequence, Tuple 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 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.discount_offer import upsert_discount_offer from app.database.crud.server_squad import ( get_all_server_squads, get_server_squad_by_id, @@ -28,28 +25,19 @@ from app.database.crud.promo_offer_template import ( update_promo_offer_template, ) from app.database.crud.promo_offer_log import list_promo_offer_logs -from app.database.crud.user import get_user_by_id, get_users_for_promo_segment -from app.database.models import ( - PromoOfferLog, - PromoOfferTemplate, - SubscriptionTemporaryAccess, - User, - UserStatus, -) +from app.database.crud.user import get_users_for_promo_segment +from app.database.models import PromoOfferLog, PromoOfferTemplate, User 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.subscription_utils import get_display_subscription_link -from app.utils.formatters import format_datetime logger = logging.getLogger(__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: @@ -333,234 +321,20 @@ def _build_logs_keyboard(page: int, total_pages: int, language: str) -> InlineKe 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( + rows = [ [ InlineKeyboardButton( - text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER", - "👤 Отправка пользователю", - ), - callback_data=f"promo_offer_send_user_{template.id}_page_1", + text=label, + callback_data=f"promo_offer_send_{template.id}_{segment}", ) ] - ) - + for segment, label in segments + ] + texts = get_texts(language) 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 f"ID {user.telegram_id or user.id}").strip() - if not name: - name = f"ID {user.telegram_id or user.id}" - - if len(name) > 20: - name = name[:17] + "..." - - parts = [status_emoji, subscription_emoji, name, f"🆔 {user.telegram_id}" if user.telegram_id else f"#{user.id}"] - - 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: Optional[str] = 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", "👤 Отправка пользователю"), - "", - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_HINT", - "Выберите пользователя для отправки промопредложения.", - ), - ] - - if query: - lines.append( - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY", - "🔍 Поиск: {query}", - ).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, @@ -1096,449 +870,6 @@ async def show_send_segments(callback: CallbackQuery, db_user: User, db: AsyncSe 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 - - if page < 1: - 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 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) - await callback.message.answer( - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT", - "Введите имя, username или 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 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 - - 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 - - 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) - 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", "👤 {name}").format(name=name), - texts.t("ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM", "🆔 {telegram_id}").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) - if subscription: - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION", "💳 Подписка")) - lines.append( - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS", - "Статус: {status}", - ).format(status=subscription.status_display) - ) - lines.append( - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END", - "Истекает: {date}", - ).format(date=format_datetime(subscription.end_date, db_user.language)) - ) - 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.utcnow() - 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: - discount_line += texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL", - " (до {date})", - ).format(date=format_datetime(expires_at, db_user.language)) - 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) - - 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", "📨 Активные предложения:")) - 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 - expires_text = ( - format_datetime(offer.expires_at, db_user.language) - 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_ITEM", - "• {description} (до {expires})", - ).format(description=description, expires=expires_text) - ) - else: - lines.append("") - lines.append( - texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS", - "📨 Активных предложений нет", - ) - ) - - if subscription: - now = datetime.utcnow() - result = await db.execute( - select(SubscriptionTemporaryAccess) - .options(selectinload(SubscriptionTemporaryAccess.offer)) - .where( - SubscriptionTemporaryAccess.subscription_id == subscription.id, - SubscriptionTemporaryAccess.is_active == True, # noqa: E712 - 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, db_user.language) - 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]]: subscription = getattr(user, "subscription", None) if not subscription: @@ -1597,75 +928,6 @@ def _build_connect_button_rows(user: User, texts) -> List[List[InlineKeyboardBut return rows -async def _send_offer_to_users( - bot, - template: PromoOfferTemplate, - db_user: User, - db: AsyncSession, - users: Sequence[User], - *, - squad_name: Optional[str], - effect_type: str, -) -> Tuple[int, int]: - sent = 0 - failed = 0 - - for user in users: - try: - offer_record = await upsert_discount_offer( - db, - user_id=user.id, - subscription_id=user.subscription.id if user.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, - }, - ) - - user_texts = get_texts(user.language or db_user.language) - keyboard_rows: List[List[InlineKeyboardButton]] = [ - [InlineKeyboardButton(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", - ) - sent += 1 - except (TelegramForbiddenError, TelegramBadRequest) as exc: - logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc) - failed += 1 - except Exception as exc: # pragma: no cover - defensive logging - logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc) - failed += 1 - - return sent, failed - - @admin_required @error_handler async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: AsyncSession): @@ -1712,17 +974,63 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn await callback.message.answer(texts.t("ADMIN_PROMO_OFFER_NO_USERS", "Подходящих пользователей не найдено.")) return + sent = 0 + failed = 0 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, - ) + + for user in users: + try: + offer_record = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=user.subscription.id if user.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, + }, + ) + + user_texts = get_texts(user.language or db_user.language) + keyboard_rows: List[List[InlineKeyboardButton]] = [ + [InlineKeyboardButton(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 callback.bot.send_message( + chat_id=user.telegram_id, + text=message_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + sent += 1 + except (TelegramForbiddenError, TelegramBadRequest) as exc: + logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc) + failed += 1 + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc) + failed += 1 summary = texts.t( "ADMIN_PROMO_OFFER_RESULT", @@ -1758,131 +1066,6 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn ) -@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") @@ -2044,13 +1227,7 @@ def register_handlers(dp: Dispatcher): 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_")) @@ -2061,4 +1238,3 @@ def register_handlers(dp: Dispatcher): 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) diff --git a/app/states.py b/app/states.py index ab6a0eab..dd70dbf9 100644 --- a/app/states.py +++ b/app/states.py @@ -113,8 +113,6 @@ class AdminStates(StatesGroup): editing_promo_offer_discount = State() editing_promo_offer_test_duration = State() editing_promo_offer_squads = State() - selecting_promo_offer_user = State() - searching_promo_offer_user = State() # Состояния для отслеживания источника перехода viewing_user_from_balance_list = State()