diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 51de3032..1cb146f7 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -1,61 +1,39 @@
-import html
import logging
from datetime import datetime, timedelta
-from typing import Optional, List, Tuple
-
+from typing import Optional
from aiogram import Dispatcher, types, F
-from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
-from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
+from app.states import AdminStates
+from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
+from app.database.crud.user import get_user_by_id
from app.database.crud.campaign import (
get_campaign_registration_by_user,
get_campaign_statistics,
)
-from app.database.crud.discount_offer import upsert_discount_offer
-from app.database.crud.promo_group import get_promo_groups_with_counts
-from app.database.crud.promo_offer_template import (
- ensure_default_templates,
- get_promo_offer_template_by_id,
- list_promo_offer_templates,
+from app.keyboards.admin import (
+ get_admin_users_keyboard, get_user_management_keyboard,
+ get_admin_pagination_keyboard, get_confirmation_keyboard,
+ get_admin_users_filters_keyboard, get_user_promo_group_keyboard
)
+from app.localization.texts import get_texts
+from app.services.user_service import UserService
+from app.services.admin_notification_service import AdminNotificationService
+from app.database.crud.promo_group import get_promo_groups_with_counts
+from app.utils.decorators import admin_required, error_handler
+from app.utils.formatters import format_datetime, format_time_ago
+from app.services.remnawave_service import RemnaWaveService
+from app.external.remnawave_api import TrafficLimitStrategy
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_uuid,
get_server_squad_by_id,
get_server_ids_by_uuids,
)
-from app.database.crud.user import get_user_by_id
-from app.database.models import (
- Subscription,
- SubscriptionStatus,
- TransactionType,
- User,
- UserStatus,
-)
-from app.external.remnawave_api import TrafficLimitStrategy
-from app.keyboards.admin import (
- get_admin_pagination_keyboard,
- get_admin_users_filters_keyboard,
- get_admin_users_keyboard,
- get_confirmation_keyboard,
- get_user_management_keyboard,
- get_user_promo_group_keyboard,
-)
-from app.localization.texts import get_texts
-from app.services.admin_notification_service import AdminNotificationService
-from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
-from app.services.user_service import UserService
-from app.states import AdminStates
-from app.utils.decorators import admin_required, error_handler
-from app.utils.formatters import format_datetime, format_time_ago
-from app.utils.promo_offer import (
- build_promo_offer_timer_line,
- get_user_active_promo_discount_percent,
-)
logger = logging.getLogger(__name__)
@@ -1019,12 +997,6 @@ async def _render_user_subscription_overview(
user = profile["user"]
subscription = profile["subscription"]
- if subscription is not None:
- try:
- setattr(user, "subscription", subscription)
- except Exception:
- pass
-
text = "📱 Подписка и настройки пользователя\n\n"
text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n"
@@ -1476,44 +1448,6 @@ async def show_user_management(
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
- promo_percent = get_user_active_promo_discount_percent(user)
- promo_expires_at = getattr(user, "promo_offer_discount_expires_at", None)
- promo_source = getattr(user, "promo_offer_discount_source", None)
-
- if promo_percent > 0:
- expires_text = (
- format_datetime(promo_expires_at)
- if promo_expires_at
- else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞")
- )
- source_text = (
- promo_source
- if promo_source
- else texts.get("ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN", "—")
- )
- promo_section = texts.get(
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE",
- "🎯 Активное промо: {percent}% (источник: {source})\nДействует до: {expires_at}",
- ).format(
- percent=promo_percent,
- source=source_text,
- expires_at=expires_text,
- )
- try:
- timer_line = await build_promo_offer_timer_line(db, user, texts)
- except Exception:
- timer_line = None
- if timer_line:
- promo_section += f"\n{timer_line}"
- sections.append(promo_section)
- else:
- sections.append(
- texts.get(
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE",
- "🎯 Активное промо: отсутствует",
- )
- )
-
text = "\n\n".join(sections)
# Проверяем состояние, чтобы определить, откуда пришел пользователь
@@ -1551,182 +1485,6 @@ async def show_user_management(
await callback.answer()
-PROMO_TEMPLATE_ICONS = {
- "test_access": "🧪",
- "extend_discount": "💎",
- "purchase_discount": "🎯",
-}
-
-PROMO_TEMPLATE_EFFECT_TYPES = {
- "test_access": "test_access",
- "extend_discount": "percent_discount",
- "purchase_discount": "percent_discount",
-}
-
-
-def _build_user_template_button_text(template) -> str:
- icon = PROMO_TEMPLATE_ICONS.get(template.offer_type, "📨")
- suffix = ""
- try:
- percent = int(template.discount_percent or 0)
- except (TypeError, ValueError):
- percent = 0
- if template.offer_type != "test_access" and percent > 0:
- suffix = f" ({percent}%)"
- return f"{icon} {template.name}{suffix}"
-
-
-def _render_promo_template_text(template, language: str, *, server_name: Optional[str] = None) -> str:
- replacements = {
- "discount_percent": template.discount_percent,
- "valid_hours": template.valid_hours,
- "test_duration_hours": template.test_duration_hours or 0,
- "active_discount_hours": template.active_discount_hours or template.valid_hours,
- }
-
- if server_name is not None:
- replacements.setdefault("server_name", server_name)
- else:
- replacements.setdefault("server_name", "???")
-
- try:
- return template.message_text.format(**replacements)
- except Exception:
- logger.warning(
- "Не удалось форматировать текст промо-предложения %s для языка %s",
- template.id,
- language,
- )
- return template.message_text
-
-
-async def _resolve_template_squad_info(
- db: AsyncSession,
- template,
-) -> Tuple[Optional[str], Optional[str]]:
- if template.offer_type != "test_access":
- return None, None
-
- squad_uuids = template.test_squad_uuids or []
- if not squad_uuids:
- return None, None
-
- squad_uuid = str(squad_uuids[0])
- server = await get_server_squad_by_uuid(db, squad_uuid)
- server_name = server.display_name if server else None
- return squad_uuid, server_name
-
-
-def _get_template_effect_type(template) -> str:
- return PROMO_TEMPLATE_EFFECT_TYPES.get(template.offer_type, "percent_discount")
-
-
-def _get_template_restriction_key(template, subscription: Optional[Subscription]) -> Optional[str]:
- if not subscription:
- return None
-
- if subscription.is_trial and template.offer_type == "extend_discount":
- return "trial"
-
- if subscription.is_active and not subscription.is_trial and template.offer_type == "purchase_discount":
- return "active_paid"
-
- return None
-
-
-def _get_template_restriction_message(key: str, texts) -> str:
- if key == "trial":
- return texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_TRIAL",
- "⚠️ Шаблоны продления недоступны для пользователей на тестовом периоде.",
- )
-
- if key == "active_paid":
- return texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_ACTIVE",
- "⚠️ Скидки на покупку подписки недоступны для пользователей с активной подпиской.",
- )
-
- return ""
-
-
-def _build_user_promo_template_preview_content(language: str, preview_data: dict):
- texts = get_texts(language)
- template_name = html.escape(preview_data.get("template_name", ""))
- user_name = html.escape(preview_data.get("target_user_name", ""))
- button_text = html.escape(preview_data.get("button_text", ""))
- message_text = preview_data.get("message_text", "")
-
- lines = [
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_TITLE",
- "📨 Предпросмотр «{name}» для {user}",
- ).format(name=template_name, user=user_name),
- "",
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_HINT",
- "Отредактируйте текст при необходимости и подтвердите отправку.",
- ),
- "",
- message_text,
- ]
-
- if button_text:
- lines.extend(
- [
- "",
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_BUTTON",
- "Кнопка пользователя: «{text}»",
- ).format(text=button_text),
- ]
- )
-
- preview_text = "\n".join(lines)
-
- user_id = preview_data.get("user_id")
- template_id = preview_data.get("template_id")
-
- keyboard = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_EDIT",
- "✏️ Редактировать текст",
- ),
- callback_data=f"admin_user_promo_edit_{user_id}_{template_id}",
- )
- ],
- [
- InlineKeyboardButton(
- text=texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_CONFIRM",
- "✅ Отправить",
- ),
- callback_data=f"admin_user_promo_confirm_{user_id}_{template_id}",
- )
- ],
- [
- InlineKeyboardButton(
- text=texts.BACK,
- callback_data=f"admin_user_promo_templates_{user_id}",
- )
- ],
- ]
- )
-
- return preview_text, keyboard
-
-
-async def _get_promo_template_preview_data(state: FSMContext) -> Optional[dict]:
- data = await state.get_data()
- preview_data = data.get("promo_template_preview")
- if isinstance(preview_data, dict) and preview_data:
- return preview_data
- return None
-
-
async def _render_user_promo_group(
message: types.Message,
language: str,
@@ -1857,637 +1615,6 @@ async def set_user_promo_group(
)
-@admin_required
-@error_handler
-async def show_user_promo_templates(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext,
-):
- try:
- user_id = int(callback.data.split("_")[-1])
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- user_service = UserService()
- profile = await user_service.get_user_profile(db, user_id)
- if not profile:
- await callback.answer("❌ Пользователь не найден", show_alert=True)
- return
-
- target_user: User = profile["user"]
- subscription: Optional[Subscription] = profile.get("subscription")
- if subscription is not None:
- try:
- setattr(target_user, "subscription", subscription)
- except Exception:
- pass
-
- await state.update_data(promo_template_preview=None)
-
- await ensure_default_templates(db, created_by=db_user.id)
- templates = await list_promo_offer_templates(db)
-
- texts = get_texts(db_user.language)
- active_templates = [template for template in templates if getattr(template, "is_active", True)]
-
- if not active_templates:
- await callback.answer(
- texts.t("ADMIN_USER_PROMO_TEMPLATES_EMPTY", "Нет активных шаблонов промо-предложений."),
- show_alert=True,
- )
- return
-
- promo_percent = get_user_active_promo_discount_percent(target_user)
- promo_expires_at = getattr(target_user, "promo_offer_discount_expires_at", None)
- expires_text = (
- format_datetime(promo_expires_at)
- if promo_expires_at
- else texts.get("ADMIN_USER_PROMO_OFFER_NO_EXPIRY", "∞")
- )
-
- restriction_keys = set()
- available_templates = []
-
- for template in active_templates:
- restriction_key = _get_template_restriction_key(template, subscription)
- if restriction_key:
- restriction_keys.add(restriction_key)
- continue
- available_templates.append(template)
-
- message_lines = [
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATES_TITLE",
- "🎯 Промо-предложения для {name}",
- ).format(name=target_user.full_name),
- "",
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATES_HINT",
- "Выберите шаблон, чтобы отправить пользователю промо-предложение.",
- ),
- ]
-
- if promo_percent > 0:
- message_lines.extend(
- [
- "",
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO",
- "Активная скидка: {percent}% (до {expires_at})",
- ).format(percent=promo_percent, expires_at=expires_text),
- ]
- )
-
- for key in sorted(restriction_keys):
- restriction_message = _get_template_restriction_message(key, texts)
- if restriction_message:
- message_lines.extend(["", restriction_message])
-
- if not available_templates:
- message_lines.extend(
- [
- "",
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATES_UNAVAILABLE",
- "Нет доступных шаблонов для отправки этому пользователю.",
- ),
- ]
- )
-
- keyboard_rows = [
- [
- InlineKeyboardButton(
- text=texts.BACK,
- callback_data=f"admin_user_manage_{target_user.id}",
- )
- ]
- ]
-
- await state.set_state(None)
-
- await callback.message.edit_text(
- "\n".join(message_lines),
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
- parse_mode="HTML",
- )
- await callback.answer()
- return
-
- keyboard_rows: List[List[InlineKeyboardButton]] = [
- [
- InlineKeyboardButton(
- text=_build_user_template_button_text(template),
- callback_data=f"admin_user_promo_send_{target_user.id}_{template.id}",
- )
- ]
- for template in available_templates
- ]
-
- keyboard_rows.append([
- InlineKeyboardButton(
- text=texts.BACK,
- callback_data=f"admin_user_manage_{target_user.id}",
- )
- ])
-
- await state.set_state(None)
-
- await callback.message.edit_text(
- "\n".join(message_lines),
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
- parse_mode="HTML",
- )
- await callback.answer()
-
-
-@admin_required
-@error_handler
-async def send_user_promo_template(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext,
-):
- prefix = "admin_user_promo_send_"
- if not callback.data.startswith(prefix):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- payload = callback.data[len(prefix):]
- try:
- user_part, template_part = payload.split("_", 1)
- user_id = int(user_part)
- template_id = int(template_part)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- user_service = UserService()
- profile = await user_service.get_user_profile(db, user_id)
- if not profile:
- await callback.answer("❌ Пользователь не найден", show_alert=True)
- return
-
- target_user: User = profile["user"]
- subscription: Optional[Subscription] = profile.get("subscription")
- if subscription is not None:
- try:
- setattr(target_user, "subscription", subscription)
- except Exception:
- pass
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template or not getattr(template, "is_active", True):
- await callback.answer("❌ Шаблон недоступен", show_alert=True)
- return
-
- restriction_key = _get_template_restriction_key(template, subscription)
- if restriction_key:
- texts = get_texts(db_user.language)
- restriction_message = _get_template_restriction_message(restriction_key, texts)
- await callback.answer(restriction_message or "❌ Шаблон недоступен", show_alert=True)
- return
-
- squad_uuid, squad_name = await _resolve_template_squad_info(db, template)
-
- if template.offer_type == "test_access" and squad_uuid:
- connected = set(subscription.connected_squads or []) if subscription else set()
- if squad_uuid in connected:
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS",
- "У пользователя уже есть доступ к этому серверу.",
- ),
- show_alert=True,
- )
- return
-
- user_language = target_user.language or db_user.language
- message_text = _render_promo_template_text(
- template,
- user_language,
- server_name=squad_name,
- )
-
- preview_data = {
- "user_id": target_user.id,
- "template_id": template.id,
- "template_name": template.name,
- "target_user_name": target_user.full_name,
- "button_text": template.button_text,
- "message_text": message_text,
- "admin_language": db_user.language,
- "user_language": user_language,
- "message_chat_id": callback.message.chat.id,
- "message_id": callback.message.message_id,
- }
-
- await state.update_data(promo_template_preview=preview_data)
-
- preview_text, keyboard = _build_user_promo_template_preview_content(
- db_user.language,
- preview_data,
- )
-
- await state.set_state(None)
-
- await callback.message.edit_text(
- preview_text,
- reply_markup=keyboard,
- parse_mode="HTML",
- )
- await callback.answer()
-
-
-
-
-
-@admin_required
-@error_handler
-async def prompt_user_promo_template_edit(
- callback: types.CallbackQuery,
- db_user: User,
- state: FSMContext,
-):
- prefix = "admin_user_promo_edit_"
- if not callback.data.startswith(prefix):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- payload = callback.data[len(prefix):]
- try:
- user_part, template_part = payload.split("_", 1)
- user_id = int(user_part)
- template_id = int(template_part)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- preview_data = await _get_promo_template_preview_data(state)
- if (
- not preview_data
- or preview_data.get("user_id") != user_id
- or preview_data.get("template_id") != template_id
- ):
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_MISSING",
- "❌ Предпросмотр не найден. Выберите шаблон заново.",
- ),
- show_alert=True,
- )
- return
-
- await state.set_state(AdminStates.editing_user_promo_template_message)
-
- texts = get_texts(db_user.language)
- await callback.message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_PROMPT",
- "✏️ Отправьте новый текст сообщения одним сообщением.\n\nИспользуйте HTML-разметку при необходимости. Отправьте /cancel для отмены.",
- )
- )
- await callback.answer()
-
-
-@admin_required
-@error_handler
-async def process_user_promo_template_edit_message(
- message: types.Message,
- db_user: User,
- state: FSMContext,
-):
- preview_data = await _get_promo_template_preview_data(state)
- texts = get_texts(db_user.language)
-
- if not preview_data:
- await message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_MISSING",
- "❌ Предпросмотр не найден. Выберите шаблон заново.",
- )
- )
- await state.set_state(None)
- return
-
- admin_language = preview_data.get("admin_language", db_user.language)
-
- if message.text and message.text.strip().lower() == "/cancel":
- await state.set_state(None)
- preview_text, keyboard = _build_user_promo_template_preview_content(
- admin_language,
- preview_data,
- )
- try:
- await message.bot.edit_message_text(
- text=preview_text,
- chat_id=preview_data["message_chat_id"],
- message_id=preview_data["message_id"],
- reply_markup=keyboard,
- parse_mode="HTML",
- )
- except TelegramBadRequest:
- pass
- await message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_CANCELLED",
- "ℹ️ Редактирование отменено. Исходный текст сохранён.",
- )
- )
- return
-
- if not message.text:
- await message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_INVALID",
- "❌ Можно редактировать только текст сообщения.",
- )
- )
- return
-
- new_text = message.html_text or message.text
- preview_data["message_text"] = new_text
- await state.update_data(promo_template_preview=preview_data)
-
- preview_text, keyboard = _build_user_promo_template_preview_content(
- admin_language,
- preview_data,
- )
-
- try:
- await message.bot.edit_message_text(
- text=preview_text,
- chat_id=preview_data["message_chat_id"],
- message_id=preview_data["message_id"],
- reply_markup=keyboard,
- parse_mode="HTML",
- )
- except TelegramBadRequest as exc:
- admin_identifier = getattr(db_user, "telegram_id", getattr(db_user, "id", "unknown"))
- logger.warning(
- "Failed to update promo template preview for admin %s: %s",
- admin_identifier,
- exc,
- )
- await message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_INVALID",
- "❌ Можно редактировать только текст сообщения.",
- )
- )
- return
-
- await state.set_state(None)
- await message.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_SUCCESS",
- "✅ Текст обновлён. Проверьте предпросмотр выше.",
- )
- )
-
-
-@admin_required
-@error_handler
-async def confirm_user_promo_template(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext,
-):
- prefix = "admin_user_promo_confirm_"
- if not callback.data.startswith(prefix):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- payload = callback.data[len(prefix):]
- try:
- user_part, template_part = payload.split("_", 1)
- user_id = int(user_part)
- template_id = int(template_part)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- preview_data = await _get_promo_template_preview_data(state)
- if (
- not preview_data
- or preview_data.get("user_id") != user_id
- or preview_data.get("template_id") != template_id
- ):
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_MISSING",
- "❌ Предпросмотр не найден. Выберите шаблон заново.",
- ),
- show_alert=True,
- )
- return
-
- message_text = preview_data.get("message_text", "")
- if not message_text.strip():
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_INVALID",
- "❌ Можно редактировать только текст сообщения.",
- ),
- show_alert=True,
- )
- return
-
- user_service = UserService()
- profile = await user_service.get_user_profile(db, user_id)
- if not profile:
- await callback.answer("❌ Пользователь не найден", show_alert=True)
- return
-
- target_user: User = profile["user"]
- subscription: Optional[Subscription] = profile.get("subscription")
- if subscription is not None:
- try:
- setattr(target_user, "subscription", subscription)
- except Exception:
- pass
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template or not getattr(template, "is_active", True):
- await callback.answer("❌ Шаблон недоступен", show_alert=True)
- return
-
- restriction_key = _get_template_restriction_key(template, subscription)
- if restriction_key:
- texts = get_texts(db_user.language)
- restriction_message = _get_template_restriction_message(restriction_key, texts)
- await callback.answer(restriction_message or "❌ Шаблон недоступен", show_alert=True)
- return
-
- squad_uuid, squad_name = await _resolve_template_squad_info(db, template)
-
- if template.offer_type == "test_access" and squad_uuid:
- connected = set(subscription.connected_squads or []) if subscription else set()
- if squad_uuid in connected:
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS",
- "У пользователя уже есть доступ к этому серверу.",
- ),
- show_alert=True,
- )
- return
-
- effect_type = _get_template_effect_type(template)
-
- try:
- offer_record = await upsert_discount_offer(
- db,
- user_id=target_user.id,
- subscription_id=subscription.id if subscription else None,
- notification_type=f"promo_template_{template.id}",
- discount_percent=template.discount_percent,
- bonus_amount_kopeks=0,
- valid_hours=template.valid_hours,
- effect_type=effect_type,
- extra_data={
- "template_id": template.id,
- "offer_type": template.offer_type,
- "test_duration_hours": template.test_duration_hours,
- "test_squad_uuids": template.test_squad_uuids,
- "active_discount_hours": template.active_discount_hours,
- },
- )
- except Exception as exc:
- logger.error(
- "Failed to upsert discount offer for template %s and user %s: %s",
- template_id,
- user_id,
- exc,
- )
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR",
- "Не удалось подготовить промо-предложение.",
- ),
- show_alert=True,
- )
- return
-
- user_language = target_user.language or preview_data.get("user_language") or db_user.language
- user_texts = get_texts(user_language)
- keyboard_rows: List[List[InlineKeyboardButton]] = [
- [
- InlineKeyboardButton(
- text=template.button_text,
- callback_data=f"claim_discount_{offer_record.id}",
- )
- ],
- [
- InlineKeyboardButton(
- text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
- callback_data="promo_offer_close",
- )
- ],
- ]
-
- keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
-
- try:
- await callback.bot.send_message(
- chat_id=target_user.telegram_id,
- text=message_text,
- reply_markup=keyboard,
- parse_mode="HTML",
- )
- except TelegramForbiddenError:
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED",
- "Пользователь заблокировал бота. Сообщение не отправлено.",
- ),
- show_alert=True,
- )
- return
- except TelegramBadRequest as exc:
- logger.warning(
- "Telegram API error while sending promo template %s to %s: %s",
- template_id,
- target_user.telegram_id,
- exc,
- )
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED",
- "Не удалось отправить сообщение пользователю.",
- ),
- show_alert=True,
- )
- return
- except Exception as exc:
- logger.error(
- "Unexpected error while sending promo template %s to %s: %s",
- template_id,
- target_user.telegram_id,
- exc,
- )
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR",
- "Не удалось отправить промо-предложение пользователю.",
- ),
- show_alert=True,
- )
- return
-
- texts = get_texts(db_user.language)
- success_text = texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS",
- "✅ Промо-предложение «{name}» отправлено пользователю {user}.",
- ).format(name=template.name, user=target_user.full_name)
-
- reply_markup = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN",
- "📨 Отправить другое",
- ),
- callback_data=f"admin_user_promo_templates_{target_user.id}",
- )
- ],
- [
- InlineKeyboardButton(
- text=texts.BACK,
- callback_data=f"admin_user_manage_{target_user.id}",
- )
- ],
- ]
- )
-
- await state.update_data(promo_template_preview=None)
- await state.set_state(None)
-
- await callback.message.edit_text(
- success_text,
- reply_markup=reply_markup,
- parse_mode="HTML",
- )
- await callback.answer(
- texts.t(
- "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE",
- "Промо-предложение отправлено.",
- )
- )
@admin_required
@error_handler
@@ -4842,31 +3969,6 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_promo_group_set_")
)
- dp.callback_query.register(
- show_user_promo_templates,
- F.data.startswith("admin_user_promo_templates_")
- )
-
- dp.callback_query.register(
- send_user_promo_template,
- F.data.startswith("admin_user_promo_send_")
- )
-
- dp.callback_query.register(
- prompt_user_promo_template_edit,
- F.data.startswith("admin_user_promo_edit_"),
- )
-
- dp.callback_query.register(
- confirm_user_promo_template,
- F.data.startswith("admin_user_promo_confirm_"),
- )
-
- dp.message.register(
- process_user_promo_template_edit_message,
- AdminStates.editing_user_promo_template_message,
- )
-
dp.callback_query.register(
start_balance_edit,
F.data.startswith("admin_user_balance_")
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 4a5f45fb..2f5200cf 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -724,12 +724,6 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
callback_data=f"admin_user_promo_group_{user_id}"
)
],
- [
- InlineKeyboardButton(
- text=texts.get("ADMIN_USER_PROMO_TEMPLATES_BUTTON", "🎯 Промо-предложения"),
- callback_data=f"admin_user_promo_templates_{user_id}"
- )
- ],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
diff --git a/app/states.py b/app/states.py
index df853d3e..dd70dbf9 100644
--- a/app/states.py
+++ b/app/states.py
@@ -30,10 +30,9 @@ class PromoCodeStates(StatesGroup):
waiting_for_referral_code = State()
class AdminStates(StatesGroup):
-
+
waiting_for_user_search = State()
editing_user_balance = State()
- editing_user_promo_template_message = State()
extending_subscription = State()
adding_traffic = State()
granting_subscription = State()
diff --git a/locales/en.json b/locales/en.json
index 7d6d164a..c62cfe8d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -240,7 +240,6 @@
"ATTACHMENTS_SENT": "✅ Attachments sent.",
"DELETE_MESSAGE": "🗑 Delete",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
- "ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Promo offers",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
@@ -785,34 +784,6 @@
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Subscription: None",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Promo group:\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Promo group: Not assigned",
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 Active promo: {percent}% (source: {source})\nValid until: {expires_at}",
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 Active promo: none",
- "ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "unknown",
- "ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "no expiry",
- "ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 Promo offers for {name}",
- "ADMIN_USER_PROMO_TEMPLATES_HINT": "Choose a template to send a promo offer to the user.",
- "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Active discount: {percent}% (until {expires_at})",
- "ADMIN_USER_PROMO_TEMPLATES_EMPTY": "No active promo offer templates.",
- "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "The user already has access to this server.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Failed to send the promo offer to the user.",
- "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Failed to deliver the message to the user.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Promo offer “{name}” sent to {user}.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Send another",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Promo offer sent.",
-
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_TRIAL": "⚠️ Renewal templates are unavailable for users on a trial period.",
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_ACTIVE": "⚠️ Purchase discount templates are unavailable for users with an active paid subscription.",
- "ADMIN_USER_PROMO_TEMPLATES_UNAVAILABLE": "There are no templates available to send to this user.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_TITLE": "📨 Preview “{name}” for {user}",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_HINT": "Review the message, edit it if needed, and confirm the send.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_BUTTON": "User button: “{text}”",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_EDIT": "✏️ Edit text",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_CONFIRM": "✅ Send",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_PROMPT": "✏️ Send the new message text in a single message.\n\nUse HTML formatting if necessary. Send /cancel to abort.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_INVALID": "❌ Only text messages can be used for editing.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_CANCELLED": "ℹ️ Editing cancelled. Original text kept.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_SUCCESS": "✅ Text updated. Check the preview above.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_MISSING": "❌ Preview not found. Please choose the template again.",
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
diff --git a/locales/ru.json b/locales/ru.json
index ec025301..7a075381 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -96,7 +96,6 @@
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
"DELETE_MESSAGE": "🗑 Удалить",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
- "ADMIN_USER_PROMO_TEMPLATES_BUTTON": "🎯 Промо-предложения",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 Промогруппа пользователя",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
@@ -785,34 +784,6 @@
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "Подписка: Отсутствует",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "Промогруппа:\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "Промогруппа: Не назначена",
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_ACTIVE": "🎯 Активное промо: {percent}% (источник: {source})\nДействует до: {expires_at}",
- "ADMIN_USER_MANAGEMENT_PROMO_OFFER_NONE": "🎯 Активное промо: отсутствует",
- "ADMIN_USER_PROMO_OFFER_SOURCE_UNKNOWN": "неизвестно",
- "ADMIN_USER_PROMO_OFFER_NO_EXPIRY": "без ограничения",
- "ADMIN_USER_PROMO_TEMPLATES_TITLE": "🎯 Промо-предложения для {name}",
- "ADMIN_USER_PROMO_TEMPLATES_HINT": "Выберите шаблон, чтобы отправить промо-предложение пользователю.",
- "ADMIN_USER_PROMO_TEMPLATES_ACTIVE_INFO": "Активная скидка: {percent}% (до {expires_at})",
- "ADMIN_USER_PROMO_TEMPLATES_EMPTY": "Нет активных шаблонов промо-предложений.",
- "ADMIN_USER_PROMO_TEMPLATE_ALREADY_HAS_ACCESS": "У пользователя уже есть доступ к этому серверу.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_ERROR": "Не удалось отправить промо-предложение пользователю.",
- "ADMIN_USER_PROMO_TEMPLATE_DELIVERY_FAILED": "Не удалось отправить сообщение пользователю.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_SUCCESS": "✅ Промо-предложение «{name}» отправлено пользователю {user}.",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_AGAIN": "📨 Отправить другое",
- "ADMIN_USER_PROMO_TEMPLATE_SEND_DONE": "Промо-предложение отправлено.",
-
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_TRIAL": "⚠️ Шаблоны продления недоступны для пользователей на тестовом периоде.",
- "ADMIN_USER_PROMO_TEMPLATE_RESTRICTED_ACTIVE": "⚠️ Скидки на покупку подписки недоступны для пользователей с активной подпиской.",
- "ADMIN_USER_PROMO_TEMPLATES_UNAVAILABLE": "Нет доступных шаблонов для отправки этому пользователю.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_TITLE": "📨 Предпросмотр «{name}» для {user}",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_HINT": "Отредактируйте текст при необходимости и подтвердите отправку.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_BUTTON": "Кнопка пользователя: «{text}»",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_EDIT": "✏️ Редактировать текст",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_CONFIRM": "✅ Отправить",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_PROMPT": "✏️ Отправьте новый текст сообщения одним сообщением.\n\nИспользуйте HTML-разметку при необходимости. Отправьте /cancel для отмены.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_INVALID": "❌ Можно редактировать только текст сообщения.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_CANCELLED": "ℹ️ Редактирование отменено. Исходный текст сохранён.",
- "ADMIN_USER_PROMO_TEMPLATE_EDIT_SUCCESS": "✅ Текст обновлён. Проверьте предпросмотр выше.",
- "ADMIN_USER_PROMO_TEMPLATE_PREVIEW_MISSING": "❌ Предпросмотр не найден. Выберите шаблон заново.",
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",