diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..89c9a867 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,7 +3,8 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Dispatcher, F, types +from aiogram import Bot, Dispatcher, F, types +from aiogram.exceptions import MessageNotModified, TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -40,6 +41,65 @@ class PollCreationStates(StatesGroup): waiting_for_questions = State() +def _get_creation_header(texts) -> str: + return texts.t("ADMIN_POLLS_CREATION_HEADER", "🗳️ Создание опроса") + + +def _format_creation_prompt(texts, body: str, error: str | None = None) -> str: + header = _get_creation_header(texts) + body_content = body.strip() + if body_content.startswith(header): + body_content = body_content[len(header) :].lstrip("\n") + + sections = [header] + if error: + sections.append(error) + if body_content: + sections.append(body_content) + + return "\n\n".join(sections) + + +async def _delete_user_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + logger.debug("Failed to delete poll creation input: %s", error) + + +async def _update_creation_message( + bot: Bot, + chat_id: int, + message_id: int | None, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = None, +) -> int: + if message_id: + try: + await bot.edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return message_id + except MessageNotModified: + return message_id + except TelegramBadRequest as error: + logger.debug("Failed to edit poll creation prompt: %s", error) + + new_message = await bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return new_message.message_id + + def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) keyboard: list[list[types.InlineKeyboardButton]] = [] @@ -277,7 +337,6 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) - await state.update_data(questions=[]) await callback.message.edit_text( texts.t( @@ -286,6 +345,11 @@ async def start_poll_creation( ), parse_mode="HTML", ) + await state.update_data( + questions=[], + prompt_message_id=callback.message.message_id, + prompt_chat_id=callback.message.chat.id, + ) await callback.answer() @@ -297,31 +361,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - if message.text == "/cancel": + texts = get_texts(db_user.language) + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + prompt_chat_id = data.get("prompt_chat_id", message.chat.id) + + user_input = (message.text or "").strip() + + if user_input == "/cancel": await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + await _delete_user_message(message) + cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") + await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + cancel_text, reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + parse_mode="HTML", ) return - title = message.text.strip() - if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + await _delete_user_message(message) + + if not user_input: + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + prompt_body = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt_body, error_text), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) return - await state.update_data(title=title) + await state.update_data(title=user_input) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( + prompt_body = ( texts.t( "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) - + f"\n\n{get_html_help_text()}", + + f"\n\n{get_html_help_text()}" + ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt_body), parse_mode="HTML", ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) @admin_required @@ -333,36 +432,74 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + prompt_chat_id = data.get("prompt_chat_id", message.chat.id) - if message.text == "/cancel": + user_input = message.text or "" + + if user_input == "/cancel": await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + await _delete_user_message(message) + cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") + await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + cancel_text, reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + parse_mode="HTML", ) return - description: Optional[str] - if message.text == "/skip": - description = None + await _delete_user_message(message) + + if user_input == "/skip": + description: Optional[str] = None else: - description = message.text.strip() + description = user_input.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + error_text = texts.t( + "ADMIN_POLLS_CREATION_INVALID_HTML", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message) + prompt_body = ( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}" ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt_body, error_text), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) + prompt_body = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + ( + "Укажите награду за прохождение опроса (в рублях).\n" + "0 — без награды. Можно использовать дробные значения.\n" + "Например: 0, 0.5, 10" + ), ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt_body), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +525,50 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + prompt_chat_id = data.get("prompt_chat_id", message.chat.id) - if message.text == "/cancel": + user_input = message.text or "" + + if user_input == "/cancel": await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + await _delete_user_message(message) + cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") + await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + cancel_text, reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + parse_mode="HTML", ) return - reward_kopeks = _parse_reward_amount(message.text) + await _delete_user_message(message) + + reward_kopeks = _parse_reward_amount(user_input) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + prompt_body = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + ( + "Укажите награду за прохождение опроса (в рублях).\n" + "0 — без награды. Можно использовать дробные значения.\n" + "Например: 0, 0.5, 10" + ), + ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt_body, error_text), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) return reward_enabled = reward_kopeks > 0 @@ -418,7 +587,14 @@ async def process_poll_reward( "Отправьте /done, когда вопросы будут добавлены." ), ) - await message.answer(prompt) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) @admin_required @@ -430,21 +606,52 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - if message.text == "/cancel": + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + prompt_chat_id = data.get("prompt_chat_id", message.chat.id) + + user_input = message.text or "" + + if user_input == "/cancel": await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + await _delete_user_message(message) + cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") + await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + cancel_text, reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + parse_mode="HTML", ) return - if message.text == "/done": - data = await state.get_data() + await _delete_user_message(message) + + if user_input == "/done": questions = data.get("questions", []) if not questions: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), + error_text = texts.t( + "ADMIN_POLLS_CREATION_NEEDS_QUESTION", + "❌ Добавьте хотя бы один вопрос.", ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt, error_text), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) return title = data.get("title") @@ -462,47 +669,80 @@ async def process_poll_question( questions=questions, ) - await state.clear() - reward_text = _format_reward_text(poll, db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", - ).format( - title=poll.title, - count=len(poll.questions), - reward=reward_text, - ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + final_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + polls_keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + final_text, + reply_markup=polls_keyboard, parse_mode="HTML", ) + await state.clear() return - lines = [line.strip() for line in message.text.splitlines() if line.strip()] + lines = [line.strip() for line in user_input.splitlines() if line.strip()] if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, prompt, error_text), + parse_mode="HTML", + ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) return question_text = lines[0] options = lines[1:] - data = await state.get_data() questions = data.get("questions", []) questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=question_text), + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + confirmation = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + body = f"{confirmation}\n\n{prompt}" + new_message_id = await _update_creation_message( + message.bot, + prompt_chat_id, + prompt_message_id, + _format_creation_prompt(texts, body), parse_mode="HTML", ) + await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -689,7 +929,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + reply_markup=_build_poll_details_keyboard(poll_id, db_user.language), parse_mode="HTML", ) await callback.answer() diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..b6862e43 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from dataclasses import dataclass from typing import Iterable from aiogram import Bot @@ -22,7 +23,16 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: +@dataclass(frozen=True) +class PollSnapshot: + id: int + title: str + description: str | None + reward_enabled: bool + reward_amount_kopeks: int + + +def _build_poll_invitation_text(poll: PollSnapshot, user: User) -> str: texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] @@ -66,6 +76,14 @@ async def send_poll_to_users( poll: Poll, users: Iterable[User], ) -> dict: + poll_info = PollSnapshot( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + sent = 0 failed = 0 skipped = 0 @@ -74,7 +92,7 @@ async def send_poll_to_users( existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_info.id, PollResponse.user_id == user.id, ) ) @@ -84,7 +102,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_info.id, user_id=user.id, ) db.add(response) @@ -92,7 +110,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_info, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -112,7 +130,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_info.id, user.telegram_id, error, )