diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py
index a545d665..1f308312 100644
--- a/app/handlers/admin/promo_offers.py
+++ b/app/handlers/admin/promo_offers.py
@@ -11,11 +11,6 @@ from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMar
from sqlalchemy.ext.asyncio import AsyncSession
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,
- get_server_squad_by_uuid,
-)
from app.database.crud.promo_offer_template import (
ensure_default_templates,
get_promo_offer_template_by_id,
@@ -31,9 +26,6 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
-SQUADS_PAGE_LIMIT = 10
-
-
OFFER_TYPE_CONFIG = {
"test_access": {
"icon": "🧪",
@@ -288,154 +280,12 @@ async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: Async
@error_handler
async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split("_")[-1])
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template:
- await callback.answer("❌ Предложение не найдено", show_alert=True)
- return
-
- await state.update_data(
- selected_promo_offer=template.id,
- promo_edit_message_id=callback.message.message_id,
- promo_edit_chat_id=callback.message.chat.id,
- )
-
- await _render_squad_selection(callback, template, db, db_user.language)
- await callback.answer()
-
-
-async def _render_squad_selection(
- callback: CallbackQuery,
- template: PromoOfferTemplate,
- db: AsyncSession,
- language: str,
- page: int = 1,
-):
- texts = get_texts(language)
-
- squads, total_count = await get_all_server_squads(
- db,
- available_only=False,
- page=page,
- limit=SQUADS_PAGE_LIMIT,
- )
-
- if total_count == 0:
- await callback.message.edit_text(
- texts.t("ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE", "❌ Доступные серверы не найдены."),
- reply_markup=InlineKeyboardMarkup(
- inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_squad_back_{template.id}")]]
- ),
- )
- return
-
- selected_uuid = None
- if template.test_squad_uuids:
- selected_uuid = str(template.test_squad_uuids[0])
-
- selected_server_name = None
- if selected_uuid:
- selected_server = next((srv for srv in squads if srv.squad_uuid == selected_uuid), None)
- if not selected_server:
- selected_server = await get_server_squad_by_uuid(db, selected_uuid)
- if selected_server:
- selected_server_name = selected_server.display_name
-
- header = texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE", "🌍 Выберите сквад")
- if selected_server_name:
- current = texts.t(
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD",
- "Текущий сквад: {name}",
- ).format(name=selected_server_name)
- elif selected_uuid:
- current = texts.t(
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID",
- "Текущий сквад: {uuid}",
- ).format(uuid=selected_uuid)
- else:
- current = texts.t(
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY",
- "Текущий сквад: не выбран",
- )
-
- hint = texts.t(
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT",
- "Выберите сервер для тестового доступа из списка ниже.",
- )
-
- total_pages = (total_count + SQUADS_PAGE_LIMIT - 1) // SQUADS_PAGE_LIMIT or 1
- page = max(1, min(page, total_pages))
-
- lines = [header, "", current, "", hint]
- if total_pages > 1:
- lines.append(
- texts.t(
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE",
- "Страница {page}/{total}",
- ).format(page=page, total=total_pages)
- )
-
- text = "\n".join(lines)
-
- keyboard_rows: List[List[InlineKeyboardButton]] = []
- for server in squads:
- emoji = "✅" if server.squad_uuid == selected_uuid else ("⚪" if server.is_available else "🔒")
- label = f"{emoji} {server.display_name}"
- keyboard_rows.append([
- InlineKeyboardButton(
- text=label,
- callback_data=f"promo_offer_select_squad_{template.id}_{server.id}_{page}",
- )
- ])
-
- if total_pages > 1:
- nav_row: List[InlineKeyboardButton] = []
- if page > 1:
- nav_row.append(
- InlineKeyboardButton(
- text="⬅️",
- callback_data=f"promo_offer_squad_page_{template.id}_{page - 1}",
- )
- )
- if page < total_pages:
- nav_row.append(
- InlineKeyboardButton(
- text="➡️",
- callback_data=f"promo_offer_squad_page_{template.id}_{page + 1}",
- )
- )
- if nav_row:
- keyboard_rows.append(nav_row)
-
- action_row = [
- InlineKeyboardButton(
- text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR", "🗑 Очистить"),
- callback_data=f"promo_offer_clear_squad_{template.id}_{page}",
- ),
- InlineKeyboardButton(
- text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK", "↩️ Назад"),
- callback_data=f"promo_offer_squad_back_{template.id}",
- ),
- ]
- keyboard_rows.append(action_row)
-
- await callback.message.edit_text(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
- parse_mode="HTML",
- )
-
-
-async def _render_offer_details(
- callback: CallbackQuery,
- template: PromoOfferTemplate,
- language: str,
-):
- description = _describe_offer(template, language)
- await callback.message.edit_text(
- description,
- reply_markup=_build_offer_detail_keyboard(template, language),
- parse_mode="HTML",
+ texts = get_texts(db_user.language)
+ prompt = texts.t(
+ "ADMIN_PROMO_OFFER_PROMPT_SQUADS",
+ "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
)
+ await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_squads)
async def _handle_edit_field(
@@ -638,128 +488,8 @@ async def process_edit_test_duration(message: Message, state: FSMContext, db: As
await _handle_edit_field(message, state, db, db_user, "test_duration_hours")
-@admin_required
-@error_handler
-async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
- try:
- prefix = "promo_offer_squad_page_"
- if not callback.data.startswith(prefix):
- raise ValueError("invalid prefix")
- payload = callback.data[len(prefix):]
- template_id_str, page_str = payload.split("_", 1)
- template_id = int(template_id_str)
- page = int(page_str)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template:
- await callback.answer("❌ Предложение не найдено", show_alert=True)
- return
-
- await state.update_data(selected_promo_offer=template.id)
- await _render_squad_selection(callback, template, db, db_user.language, page=page)
- await callback.answer()
-
-
-@admin_required
-@error_handler
-async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
- try:
- prefix = "promo_offer_select_squad_"
- if not callback.data.startswith(prefix):
- raise ValueError("invalid prefix")
- payload = callback.data[len(prefix):]
- template_id_str, server_id_str, page_str = payload.split("_", 2)
- template_id = int(template_id_str)
- server_id = int(server_id_str)
- page = int(page_str)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template:
- await callback.answer("❌ Предложение не найдено", show_alert=True)
- return
-
- server = await get_server_squad_by_id(db, server_id)
- if not server:
- await callback.answer(
- get_texts(db_user.language).t(
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND",
- "❌ Сервер не найден",
- ),
- show_alert=True,
- )
- return
-
- await update_promo_offer_template(db, template, test_squad_uuids=[server.squad_uuid])
- updated = await get_promo_offer_template_by_id(db, template.id)
- if updated:
- await state.update_data(selected_promo_offer=updated.id)
-
- texts = get_texts(db_user.language)
- await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED", "✅ Сквад обновлён"))
-
- if updated:
- await _render_offer_details(callback, updated, db_user.language)
- else:
- await _render_squad_selection(callback, template, db, db_user.language, page=page)
-
-
-@admin_required
-@error_handler
-async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
- try:
- prefix = "promo_offer_clear_squad_"
- if not callback.data.startswith(prefix):
- raise ValueError("invalid prefix")
- payload = callback.data[len(prefix):]
- template_id_str, page_str = payload.split("_", 1)
- template_id = int(template_id_str)
- page = int(page_str)
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template:
- await callback.answer("❌ Предложение не найдено", show_alert=True)
- return
-
- await update_promo_offer_template(db, template, test_squad_uuids=[])
- updated = await get_promo_offer_template_by_id(db, template.id)
- if updated:
- await state.update_data(selected_promo_offer=updated.id)
-
- texts = get_texts(db_user.language)
- await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED", "✅ Сквад очищен"))
-
- if updated:
- await _render_squad_selection(callback, updated, db, db_user.language, page=page)
- else:
- await _render_squad_selection(callback, template, db, db_user.language, page=page)
-
-
-@admin_required
-@error_handler
-async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
- try:
- template_id = int(callback.data.split("_")[-1])
- except (ValueError, AttributeError):
- await callback.answer("❌ Некорректные данные", show_alert=True)
- return
-
- template = await get_promo_offer_template_by_id(db, template_id)
- if not template:
- await callback.answer("❌ Предложение не найдено", show_alert=True)
- return
-
- await state.update_data(selected_promo_offer=template.id)
- await _render_offer_details(callback, template, db_user.language)
- await callback.answer()
+async def process_edit_test_squads(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
+ await _handle_edit_field(message, state, db, db_user, "test_squad_uuids")
def register_handlers(dp: Dispatcher):
@@ -770,10 +500,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_"))
dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_"))
dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_"))
- dp.callback_query.register(paginate_squad_selection, F.data.startswith("promo_offer_squad_page_"))
- dp.callback_query.register(select_squad_for_template, F.data.startswith("promo_offer_select_squad_"))
- dp.callback_query.register(clear_squad_for_template, F.data.startswith("promo_offer_clear_squad_"))
- dp.callback_query.register(back_to_offer_from_squads, F.data.startswith("promo_offer_squad_back_"))
dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_"))
dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_"))
dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_"))
@@ -783,3 +509,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours)
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_edit_test_squads, AdminStates.editing_promo_offer_squads)
diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py
index 873b3920..4dd2c18d 100644
--- a/app/services/promo_offer_service.py
+++ b/app/services/promo_offer_service.py
@@ -34,21 +34,10 @@ class PromoOfferService:
return False, None, None, "subscription_missing"
payload = offer.extra_data or {}
- raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
- if isinstance(raw_squads, str):
- candidates = [raw_squads]
- else:
- try:
- candidates = list(raw_squads)
- except TypeError:
- candidates = []
-
- squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
+ squad_uuids: Sequence[str] = payload.get("test_squad_uuids") or payload.get("squads") or []
if not squad_uuids:
return False, None, None, "squads_missing"
- squad_uuids = list(dict.fromkeys(squad_uuids))
-
try:
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
except (TypeError, ValueError):
diff --git a/locales/en.json b/locales/en.json
index e6eeeae1..817b8091 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -584,18 +584,7 @@
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Select a squad",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found",
- "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.",
+ "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:",
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
diff --git a/locales/ru.json b/locales/ru.json
index d135eb59..be4b4c7b 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -584,18 +584,7 @@
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 Выберите сквад",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}",
- "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен",
- "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден",
- "ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.",
+ "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",