diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index bb3ac0b4..a3bfed16 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -3,16 +3,23 @@ from __future__ import annotations import html import logging import re +from datetime import datetime, timedelta 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 upsert_discount_offer +from app.database.crud.discount_offer import ( + count_discount_offers, + list_discount_offers, + upsert_discount_offer, +) from app.database.crud.server_squad import ( get_all_server_squads, get_server_squad_by_id, @@ -25,19 +32,28 @@ 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_users_for_promo_segment -from app.database.models import PromoOfferLog, PromoOfferTemplate, User +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.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: @@ -50,6 +66,38 @@ async def _safe_delete_message(message: Message) -> None: 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( + "Не удалось удалить сообщение администратора (%s, %s): %s", + chat_id, + message_id, + exc, + ) + except TelegramForbiddenError: + logger.debug( + "Недостаточно прав для удаления сообщения администратора (%s, %s)", + chat_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", @@ -98,6 +146,104 @@ OFFER_TYPE_CONFIG = { }, } + +def _normalize_hours_value(raw: Optional[object]) -> Optional[float]: + if raw is None: + return None + + try: + value = float(raw) + except (TypeError, ValueError): + return None + + if value <= 0: + return None + + return value + + +def _format_hours_value(hours: float) -> str: + try: + value = float(hours) + except (TypeError, ValueError): + return str(hours) + + if value.is_integer(): + return str(int(value)) + + return f"{value:g}" + + +def _extract_active_hours_from_offer(offer) -> Optional[float]: + if not offer: + return None + + extra_data = getattr(offer, "extra_data", None) + if not isinstance(extra_data, dict): + return None + + for key in ("active_discount_hours", "active_hours", "duration_hours"): + value = _normalize_hours_value(extra_data.get(key)) + if value is not None: + return value + + return None + + +def _format_offer_time_left(delta: timedelta, texts) -> str: + seconds = int(delta.total_seconds()) + if seconds <= 0: + return texts.t("ADMIN_PROMO_OFFER_TIME_LEFT_EXPIRED", "истекло") + + minutes_total = max(1, (seconds + 59) // 60) + days, remainder_minutes = divmod(minutes_total, 60 * 24) + hours, minutes = divmod(remainder_minutes, 60) + + parts: List[str] = [] + if days: + parts.append( + texts.t( + "ADMIN_PROMO_OFFER_TIME_LEFT_DAYS", + "{count} дн.", + ).format(count=days) + ) + + if hours: + parts.append( + texts.t( + "ADMIN_PROMO_OFFER_TIME_LEFT_HOURS", + "{count} ч.", + ).format(count=hours) + ) + + if minutes and not days: + parts.append( + texts.t( + "ADMIN_PROMO_OFFER_TIME_LEFT_MINUTES", + "{count} мин.", + ).format(count=minutes) + ) + + if not parts: + parts.append(texts.t("ADMIN_PROMO_OFFER_TIME_LEFT_LESS_THAN_MINUTE", "<1 мин.")) + + return " ".join(parts) + + +def _resolve_offer_status_label(offer, texts, *, now: Optional[datetime] = None) -> str: + reference = now or datetime.utcnow() + + if getattr(offer, "claimed_at", None): + return texts.t("ADMIN_PROMO_OFFER_STATUS_ACCEPTED", "✅ Принято") + + expires_at = getattr(offer, "expires_at", None) + is_active = getattr(offer, "is_active", False) + if is_active and (expires_at is None or expires_at > reference): + return texts.t("ADMIN_PROMO_OFFER_STATUS_PENDING", "🕒 Не принято") + + return texts.t("ADMIN_PROMO_OFFER_STATUS_EXPIRED", "⌛ Истекло") + + def _render_template_text( template: PromoOfferTemplate, language: str, @@ -321,20 +467,248 @@ 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", []) - rows = [ + 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=label, - callback_data=f"promo_offer_send_{template.id}_{segment}", + text=texts.t( + "ADMIN_PROMO_OFFER_SEND_USER", + "👤 Отправка пользователю", + ), + callback_data=f"promo_offer_send_user_{template.id}_page_1", ) ] - 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() + notification_type = f"promo_template_{template.id}" + latest_offer_candidates = await list_discount_offers( + db, + user_id=user.id, + notification_type=notification_type, + limit=1, + ) + latest_offer = latest_offer_candidates[0] if latest_offer_candidates else None + total_template_offers = await count_discount_offers( + db, + user_id=user.id, + notification_type=notification_type, + ) + + 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, @@ -870,6 +1244,601 @@ 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 _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 + + notification_type = f"promo_template_{template.id}" + latest_offer_candidates = await list_discount_offers( + db, + user_id=user.id, + notification_type=notification_type, + limit=1, + ) + latest_offer = latest_offer_candidates[0] if latest_offer_candidates else None + total_template_offers = await count_discount_offers( + db, + user_id=user.id, + notification_type=notification_type, + ) + + 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) + + 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", + "📨 Выбранное предложение", + ) + ) + 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=_format_hours_value(template.valid_hours or 0)) + ) + + latest_offer_active_hours = _extract_active_hours_from_offer(latest_offer) + template_active_hours = _normalize_hours_value(template.active_discount_hours) + effective_active_hours = latest_offer_active_hours or template_active_hours + + if latest_offer: + status_label = _resolve_offer_status_label(latest_offer, texts, now=now) + else: + status_label = texts.t("ADMIN_PROMO_OFFER_STATUS_NOT_SENT", "📭 Не отправлялось") + + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_STATUS", + "Статус: {status}", + ).format(status=status_label) + ) + + if latest_offer and latest_offer.claimed_at: + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACCEPTED_AT", + "Принято: {date}", + ).format(date=format_datetime(latest_offer.claimed_at, db_user.language)) + ) + elif latest_offer and latest_offer.expires_at: + time_left_text = _format_offer_time_left(latest_offer.expires_at - now, texts) + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TIME_LEFT", + "Осталось: {time_left}", + ).format(time_left=time_left_text) + ) + + 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 = effective_active_hours or 0 + if active_hours: + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION", + "После активации действует {hours} ч.", + ).format(hours=_format_hours_value(active_hours)) + ) + + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TOTAL_SENT", + "Всего отправлено: {count}", + ).format(count=total_template_offers) + ) + + 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", "без срока") + ) + status_label = _resolve_offer_status_label(offer, texts, now=now) + detail_lines = [ + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS", + "Статус: {status}", + ).format(status=status_label) + ] + + if offer.expires_at: + time_left_text = _format_offer_time_left(offer.expires_at - now, texts) + detail_lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT", + "Осталось: {time}", + ).format(time=time_left_text) + ) + + active_offer_hours = _extract_active_hours_from_offer(offer) + if active_offer_hours: + detail_lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION", + "После активации: {hours} ч.", + ).format(hours=_format_hours_value(active_offer_hours)) + ) + + if detail_lines: + details_text = "\n ".join(detail_lines) + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_WITH_DETAILS", + "• {description} (до {expires})\n {details}", + ).format(description=description, expires=expires_text, details=details_text) + ) + else: + 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: @@ -928,6 +1897,75 @@ 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): @@ -974,63 +2012,17 @@ 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") - - 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 + 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", @@ -1066,6 +2058,131 @@ 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") @@ -1227,7 +2344,13 @@ 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_")) @@ -1238,3 +2361,4 @@ 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 dd70dbf9..ab6a0eab 100644 --- a/app/states.py +++ b/app/states.py @@ -113,6 +113,8 @@ 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() diff --git a/locales/en.json b/locales/en.json index c62cfe8d..be29482e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -638,6 +638,68 @@ "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed", "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used", "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled", + "ADMIN_PROMO_OFFER_SEND_USER": "👤 Send to user", + "ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 Send to a user", + "ADMIN_PROMO_OFFER_SEND_USER_HINT": "Select a user to deliver the promo offer.", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Search", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Enter name, username or user ID to search:", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Search: {query}", + "ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Clear search", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ Back to segment selection", + "ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "No matching users found. Adjust your query.", + "ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 {name}", + "ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 {telegram_id}", + "ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}", + "ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Status: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Balance: {amount}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 Subscription", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Status: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Expires: {date}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Traffic: {used}/{limit} GB", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Connected squads: {count}", + "ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 No active subscription", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Active discount: {percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (until {date})", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — source: {source}", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 No active discount", + "ADMIN_PROMO_OFFER_STATUS_ACCEPTED": "✅ Claimed", + "ADMIN_PROMO_OFFER_STATUS_PENDING": "🕒 Not claimed", + "ADMIN_PROMO_OFFER_STATUS_EXPIRED": "⌛ Expired", + "ADMIN_PROMO_OFFER_STATUS_NOT_SENT": "📭 Not sent yet", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 Selected offer", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Type: {label}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Valid for: {hours} h", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_STATUS": "Status: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TIME_LEFT": "Time left: {time_left}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACCEPTED_AT": "Claimed: {date}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Test access: {hours} h", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Discount: {percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "Active after claim for {hours} h", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TOTAL_SENT": "Total sent: {count}", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Active offers:", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Test access", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "no expiry", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": "Status: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": "Time left: {time}", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": "Active after claim: {hours} h", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (until {expires})", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_WITH_DETAILS": "• {description} (until {expires})\n {details}", + "ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Active test accesses:", + "ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (until {expires})", + "ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 No active offers", + "ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Send offer", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ Back to users", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 Back to profile", + "ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Sent to {name}", + "ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Skipped: {skipped} (already has access)", + "ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Delivery not performed", + "ADMIN_PROMO_OFFER_TIME_LEFT_DAYS": "{count} d", + "ADMIN_PROMO_OFFER_TIME_LEFT_HOURS": "{count} h", + "ADMIN_PROMO_OFFER_TIME_LEFT_MINUTES": "{count} m", + "ADMIN_PROMO_OFFER_TIME_LEFT_LESS_THAN_MINUTE": "<1 m", + "ADMIN_PROMO_OFFER_TIME_LEFT_EXPIRED": "expired", "ADMIN_SUPPORT_TICKETS": "🎫 Support tickets", "ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit", "ADMIN_SUPPORT_SETTINGS": "🛟 Support settings", diff --git a/locales/ru.json b/locales/ru.json index 7a075381..bd3605e5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -638,6 +638,68 @@ "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято", "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано", "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено", + "ADMIN_PROMO_OFFER_SEND_USER": "👤 Отправка пользователю", + "ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 Отправка пользователю", + "ADMIN_PROMO_OFFER_SEND_USER_HINT": "Выберите пользователя для отправки промопредложения.", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Поиск", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Введите имя, username или ID пользователя для поиска:", + "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Поиск: {query}", + "ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Сбросить поиск", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ К выбору категории", + "ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "Подходящие пользователи не найдены. Измените запрос поиска.", + "ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 {name}", + "ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 {telegram_id}", + "ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}", + "ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Статус: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Баланс: {amount}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 Подписка", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Статус: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Истекает: {date}", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Трафик: {used}/{limit} ГБ", + "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Подключено сквадов: {count}", + "ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 Подписка отсутствует", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Активная скидка: {percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (до {date})", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — источник: {source}", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 Активная скидка отсутствует", + "ADMIN_PROMO_OFFER_STATUS_ACCEPTED": "✅ Принято", + "ADMIN_PROMO_OFFER_STATUS_PENDING": "🕒 Не принято", + "ADMIN_PROMO_OFFER_STATUS_EXPIRED": "⌛ Истекло", + "ADMIN_PROMO_OFFER_STATUS_NOT_SENT": "📭 Не отправлялось", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 Выбранное предложение", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Тип: {label}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Действует: {hours} ч.", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_STATUS": "Статус: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TIME_LEFT": "Осталось: {time_left}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACCEPTED_AT": "Принято: {date}", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Тестовый доступ: {hours} ч.", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Скидка: {percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "После активации действует {hours} ч.", + "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TOTAL_SENT": "Всего отправлено: {count}", + "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Активные предложения:", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Тестовый доступ", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "без срока", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": "Статус: {status}", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": "Осталось: {time}", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": "После активации: {hours} ч.", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (до {expires})", + "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_WITH_DETAILS": "• {description} (до {expires})\n {details}", + "ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Активные тестовые доступы:", + "ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (до {expires})", + "ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 Активных предложений нет", + "ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Отправить предложение", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ К списку пользователей", + "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 К профилю пользователя", + "ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Отправка пользователю {name}", + "ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)", + "ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Отправка не выполнена", + "ADMIN_PROMO_OFFER_TIME_LEFT_DAYS": "{count} дн.", + "ADMIN_PROMO_OFFER_TIME_LEFT_HOURS": "{count} ч.", + "ADMIN_PROMO_OFFER_TIME_LEFT_MINUTES": "{count} мин.", + "ADMIN_PROMO_OFFER_TIME_LEFT_LESS_THAN_MINUTE": "<1 мин.", + "ADMIN_PROMO_OFFER_TIME_LEFT_EXPIRED": "истекло", "ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки", "ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов", "ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",