From f091b3ea5e85dd2bf3d14cda31d4111b18593236 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:05:16 +0300 Subject: [PATCH 01/31] Add referral rewards for MulenPay top-ups --- app/services/payment/mulenpay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 8968e7c5..dc0546ec 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -246,6 +246,22 @@ class MulenPayPaymentMixin: f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽", ) + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + payment.amount_kopeks, + getattr(self, "bot", None), + ) + except Exception as error: + logger.error( + "Ошибка обработки реферального пополнения %s: %s", + display_name, + error, + ) + if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True await db.commit() From 93554c7034fc2e5fb3c60e1242eaf0a1bf287c4e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:28:13 +0300 Subject: [PATCH 02/31] Add poll management and delivery system --- app/bot.py | 16 +- app/database/crud/poll.py | 265 ++++++ app/database/crud/user.py | 21 +- app/database/models.py | 116 ++- app/handlers/admin/polls.py | 825 ++++++++++++++++++ app/handlers/polls.py | 197 +++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 47 +- app/localization/locales/ru.json | 47 +- app/services/poll_service.py | 179 ++++ .../versions/9f0f2d5a1c7b_add_polls_tables.py | 155 ++++ 11 files changed, 1856 insertions(+), 18 deletions(-) create mode 100644 app/database/crud/poll.py create mode 100644 app/handlers/admin/polls.py create mode 100644 app/handlers/polls.py create mode 100644 app/services/poll_service.py create mode 100644 migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index f21b7534..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service from app.utils.cache import cache from app.handlers import ( - start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets + start, + menu, + subscription, + balance, + promocode, + referral, + support, + server_status, + common, + tickets, ) +from app.handlers import polls as user_polls from app.handlers import simple_subscription from app.handlers.admin import ( main as admin_main, @@ -31,6 +40,7 @@ from app.handlers.admin import ( rules as admin_rules, remnawave as admin_remnawave, statistics as admin_statistics, + polls as admin_polls, servers as admin_servers, maintenance as admin_maintenance, promo_groups as admin_promo_groups, @@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_polls.register_handlers(dp) admin_promo_groups.register_handlers(dp) admin_campaigns.register_handlers(dp) admin_promo_offers.register_handlers(dp) @@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_faq.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) + user_polls.register_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") logger.info("⚡ Зарегистрированы обработчики простой покупки") diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py new file mode 100644 index 00000000..5b3b35ad --- /dev/null +++ b/app/database/crud/poll.py @@ -0,0 +1,265 @@ +import logging +from typing import Iterable, Sequence + +from sqlalchemy import and_, delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, +) + +logger = logging.getLogger(__name__) + + +async def create_poll( + db: AsyncSession, + *, + title: str, + description: str | None, + reward_enabled: bool, + reward_amount_kopeks: int, + created_by: int | None, + questions: Sequence[dict[str, Iterable[str]]], +) -> Poll: + poll = Poll( + title=title, + description=description, + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0, + created_by=created_by, + ) + db.add(poll) + await db.flush() + + for order, question_data in enumerate(questions, start=1): + question_text = question_data.get("text", "").strip() + if not question_text: + continue + + question = PollQuestion( + poll_id=poll.id, + text=question_text, + order=order, + ) + db.add(question) + await db.flush() + + for option_order, option_text in enumerate(question_data.get("options", []), start=1): + option_text = option_text.strip() + if not option_text: + continue + option = PollOption( + question_id=question.id, + text=option_text, + order=option_order, + ) + db.add(option) + + await db.commit() + await db.refresh( + poll, + attribute_names=["questions"], + ) + return poll + + +async def list_polls(db: AsyncSession) -> list[Poll]: + result = await db.execute( + select(Poll) + .options( + selectinload(Poll.questions).options(selectinload(PollQuestion.options)) + ) + .order_by(Poll.created_at.desc()) + ) + return result.scalars().all() + + +async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None: + result = await db.execute( + select(Poll) + .options( + selectinload(Poll.questions).options(selectinload(PollQuestion.options)), + selectinload(Poll.responses), + ) + .where(Poll.id == poll_id) + ) + return result.scalar_one_or_none() + + +async def delete_poll(db: AsyncSession, poll_id: int) -> bool: + poll = await db.get(Poll, poll_id) + if not poll: + return False + + await db.delete(poll) + await db.commit() + logger.info("🗑️ Удалён опрос %s", poll_id) + return True + + +async def create_poll_response( + db: AsyncSession, + poll_id: int, + user_id: int, +) -> PollResponse: + result = await db.execute( + select(PollResponse) + .where( + and_( + PollResponse.poll_id == poll_id, + PollResponse.user_id == user_id, + ) + ) + ) + response = result.scalar_one_or_none() + if response: + return response + + response = PollResponse( + poll_id=poll_id, + user_id=user_id, + ) + db.add(response) + await db.commit() + await db.refresh(response) + return response + + +async def get_poll_response_by_id( + db: AsyncSession, + response_id: int, +) -> PollResponse | None: + result = await db.execute( + select(PollResponse) + .options( + selectinload(PollResponse.poll) + .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))), + selectinload(PollResponse.answers), + selectinload(PollResponse.user), + ) + .where(PollResponse.id == response_id) + ) + return result.scalar_one_or_none() + + +async def record_poll_answer( + db: AsyncSession, + *, + response_id: int, + question_id: int, + option_id: int, +) -> PollAnswer: + result = await db.execute( + select(PollAnswer) + .where( + and_( + PollAnswer.response_id == response_id, + PollAnswer.question_id == question_id, + ) + ) + ) + answer = result.scalar_one_or_none() + if answer: + answer.option_id = option_id + await db.commit() + await db.refresh(answer) + return answer + + answer = PollAnswer( + response_id=response_id, + question_id=question_id, + option_id=option_id, + ) + db.add(answer) + await db.commit() + await db.refresh(answer) + return answer + + +async def reset_poll_answers(db: AsyncSession, response_id: int) -> None: + await db.execute( + delete(PollAnswer).where(PollAnswer.response_id == response_id) + ) + await db.commit() + + +async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: + totals_result = await db.execute( + select( + func.count(PollResponse.id), + func.count(PollResponse.completed_at), + func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0), + ).where(PollResponse.poll_id == poll_id) + ) + total_responses, completed_responses, reward_sum = totals_result.one() + + option_counts_result = await db.execute( + select( + PollQuestion.id, + PollQuestion.text, + PollQuestion.order, + PollOption.id, + PollOption.text, + PollOption.order, + func.count(PollAnswer.id), + ) + .join(PollOption, PollOption.question_id == PollQuestion.id) + .outerjoin( + PollAnswer, + and_( + PollAnswer.question_id == PollQuestion.id, + PollAnswer.option_id == PollOption.id, + ), + ) + .where(PollQuestion.poll_id == poll_id) + .group_by( + PollQuestion.id, + PollQuestion.text, + PollQuestion.order, + PollOption.id, + PollOption.text, + PollOption.order, + ) + .order_by(PollQuestion.order.asc(), PollOption.order.asc()) + ) + + questions_map: dict[int, dict] = {} + for ( + question_id, + question_text, + question_order, + option_id, + option_text, + option_order, + answer_count, + ) in option_counts_result: + question_entry = questions_map.setdefault( + question_id, + { + "id": question_id, + "text": question_text, + "order": question_order, + "options": [], + }, + ) + question_entry["options"].append( + { + "id": option_id, + "text": option_text, + "count": answer_count, + } + ) + + questions = sorted(questions_map.values(), key=lambda item: item["order"]) + + return { + "total_responses": total_responses, + "completed_responses": completed_responses, + "reward_sum_kopeks": reward_sum, + "questions": questions, + } diff --git a/app/database/crud/user.py b/app/database/crud/user.py index e9dea8c2..67112086 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -219,7 +219,8 @@ async def add_user_balance( amount_kopeks: int, description: str = "Пополнение баланса", create_transaction: bool = True, - bot = None + transaction_type: TransactionType = TransactionType.DEPOSIT, + bot = None ) -> bool: try: old_balance = user.balance_kopeks @@ -228,12 +229,11 @@ async def add_user_balance( if create_transaction: from app.database.crud.transaction import create_transaction as create_trans - from app.database.models import TransactionType - + await create_trans( db=db, user_id=user.id, - type=TransactionType.DEPOSIT, + type=transaction_type, amount_kopeks=amount_kopeks, description=description ) @@ -253,9 +253,10 @@ async def add_user_balance( async def add_user_balance_by_id( db: AsyncSession, - telegram_id: int, + telegram_id: int, amount_kopeks: int, - description: str = "Пополнение баланса" + description: str = "Пополнение баланса", + transaction_type: TransactionType = TransactionType.DEPOSIT, ) -> bool: try: user = await get_user_by_telegram_id(db, telegram_id) @@ -263,7 +264,13 @@ async def add_user_balance_by_id( logger.error(f"Пользователь с telegram_id {telegram_id} не найден") return False - return await add_user_balance(db, user, amount_kopeks, description) + return await add_user_balance( + db, + user, + amount_kopeks, + description, + transaction_type=transaction_type, + ) except Exception as e: logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}") diff --git a/app/database/models.py b/app/database/models.py index d296aba9..b4e9bc80 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -58,11 +58,12 @@ class SubscriptionStatus(Enum): class TransactionType(Enum): - DEPOSIT = "deposit" - WITHDRAWAL = "withdrawal" - SUBSCRIPTION_PAYMENT = "subscription_payment" - REFUND = "refund" - REFERRAL_REWARD = "referral_reward" + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + SUBSCRIPTION_PAYMENT = "subscription_payment" + REFUND = "refund" + REFERRAL_REWARD = "referral_reward" + POLL_REWARD = "poll_reward" class PromoCodeType(Enum): @@ -530,6 +531,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + poll_responses = relationship("PollResponse", back_populates="user") @property def balance_rubles(self) -> float: @@ -1061,9 +1063,9 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) - target_type = Column(String(100), nullable=False) + target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) has_media = Column(Boolean, default=False) media_type = Column(String(20), nullable=True) @@ -1079,6 +1081,106 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") + +class Poll(Base): + __tablename__ = "polls" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + reward_enabled = Column(Boolean, nullable=False, default=False) + reward_amount_kopeks = Column(Integer, nullable=False, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False) + + creator = relationship("User", backref="created_polls", foreign_keys=[created_by]) + questions = relationship( + "PollQuestion", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollQuestion.order", + ) + responses = relationship( + "PollResponse", + back_populates="poll", + cascade="all, delete-orphan", + ) + + +class PollQuestion(Base): + __tablename__ = "poll_questions" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) + text = Column(Text, nullable=False) + order = Column(Integer, nullable=False, default=0) + + poll = relationship("Poll", back_populates="questions") + options = relationship( + "PollOption", + back_populates="question", + cascade="all, delete-orphan", + order_by="PollOption.order", + ) + answers = relationship("PollAnswer", back_populates="question") + + +class PollOption(Base): + __tablename__ = "poll_options" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) + text = Column(Text, nullable=False) + order = Column(Integer, nullable=False, default=0) + + question = relationship("PollQuestion", back_populates="options") + answers = relationship("PollAnswer", back_populates="option") + + +class PollResponse(Base): + __tablename__ = "poll_responses" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + sent_at = Column(DateTime, default=func.now(), nullable=False) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + reward_given = Column(Boolean, nullable=False, default=False) + reward_amount_kopeks = Column(Integer, nullable=False, default=0) + + poll = relationship("Poll", back_populates="responses") + user = relationship("User", back_populates="poll_responses") + answers = relationship( + "PollAnswer", + back_populates="response", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + + +class PollAnswer(Base): + __tablename__ = "poll_answers" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) + option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True) + created_at = Column(DateTime, default=func.now(), nullable=False) + + response = relationship("PollResponse", back_populates="answers") + question = relationship("PollQuestion", back_populates="answers") + option = relationship("PollOption", back_populates="answers") + + __table_args__ = ( + UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), + ) + + class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py new file mode 100644 index 00000000..1220cae4 --- /dev/null +++ b/app/handlers/admin/polls.py @@ -0,0 +1,825 @@ +import html +import logging +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Optional + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.poll import ( + create_poll, + delete_poll, + get_poll_by_id, + get_poll_statistics, + list_polls, +) +from app.database.models import Poll, User +from app.handlers.admin.messages import ( + get_custom_users, + get_custom_users_count, + get_target_display_name, + get_target_users, + get_target_users_count, +) +from app.keyboards.admin import get_admin_communications_submenu_keyboard +from app.localization.texts import get_texts +from app.services.poll_service import send_poll_to_users +from app.utils.decorators import admin_required, error_handler +from app.utils.validators import get_html_help_text, validate_html_tags + +logger = logging.getLogger(__name__) + + +class PollCreationStates(StatesGroup): + waiting_for_title = State() + waiting_for_description = State() + waiting_for_reward = State() + waiting_for_questions = State() + + +def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + keyboard: list[list[types.InlineKeyboardButton]] = [] + + for poll in polls[:10]: + keyboard.append( + [ + types.InlineKeyboardButton( + text=f"🗳️ {poll.title[:40]}", + callback_data=f"poll_view:{poll.id}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), + callback_data="poll_create", + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_communications", + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _format_reward_text(poll: Poll, language: str) -> str: + texts = get_texts(language) + if poll.reward_enabled and poll.reward_amount_kopeks > 0: + return texts.t( + "ADMIN_POLLS_REWARD_ENABLED", + "Награда: {amount}", + ).format(amount=settings.format_price(poll.reward_amount_kopeks)) + return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена") + + +def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"), + callback_data=f"poll_send:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"), + callback_data=f"poll_stats:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"poll_delete:{poll_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")], + ] + ) + + +def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), + callback_data=f"poll_target:{poll_id}:all", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), + callback_data=f"poll_target:{poll_id}:active", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), + callback_data=f"poll_target:{poll_id}:trial", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), + callback_data=f"poll_target:{poll_id}:no", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), + callback_data=f"poll_target:{poll_id}:expiring", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), + callback_data=f"poll_target:{poll_id}:expired", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), + callback_data=f"poll_target:{poll_id}:active_zero", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), + callback_data=f"poll_target:{poll_id}:trial_zero", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"), + callback_data=f"poll_custom_menu:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_view:{poll_id}", + ) + ], + ] + ) + + +def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"), + callback_data=f"poll_custom_target:{poll_id}:today", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"), + callback_data=f"poll_custom_target:{poll_id}:week", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"), + callback_data=f"poll_custom_target:{poll_id}:month", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), + callback_data=f"poll_custom_target:{poll_id}:active_today", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), + callback_data=f"poll_custom_target:{poll_id}:inactive_week", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), + callback_data=f"poll_custom_target:{poll_id}:inactive_month", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), + callback_data=f"poll_custom_target:{poll_id}:referrals", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), + callback_data=f"poll_custom_target:{poll_id}:direct", + ), + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")], + ] + ) + + +def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"), + callback_data=f"poll_send_confirm:{poll_id}:{target}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_send:{poll_id}", + ) + ], + ] + ) + + +@admin_required +@error_handler +async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + polls = await list_polls(db) + texts = get_texts(db_user.language) + + lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ Опросы"), ""] + if not polls: + lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет.")) + else: + for poll in polls[:10]: + reward = _format_reward_text(poll, db_user.language) + lines.append( + f"• {html.escape(poll.title)} — " + f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n" + f" {reward}" + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=_build_polls_keyboard(polls, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + 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( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_poll_title( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + if message.text == "/cancel": + await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + title = message.text.strip() + if not title: + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + return + + await state.update_data(title=title) + await state.set_state(PollCreationStates.waiting_for_description) + + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) + + +@admin_required +@error_handler +async def process_poll_description( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + description: Optional[str] + if message.text == "/skip": + description = None + else: + description = message.text.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) + ) + 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 чтобы отключить награду.", + ) + ) + + +def _parse_reward_amount(message_text: str) -> int | None: + normalized = message_text.replace(" ", "").replace(",", ".") + try: + value = Decimal(normalized) + except InvalidOperation: + return None + + if value < 0: + value = Decimal(0) + + kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + return max(0, kopeks) + + +@admin_required +@error_handler +async def process_poll_reward( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + reward_kopeks = _parse_reward_amount(message.text) + if reward_kopeks is None: + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + return + + reward_enabled = reward_kopeks > 0 + await state.update_data( + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_kopeks, + ) + await state.set_state(PollCreationStates.waiting_for_questions) + + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) + + +@admin_required +@error_handler +async def process_poll_question( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + if message.text == "/done": + data = await state.get_data() + questions = data.get("questions", []) + if not questions: + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), + ) + return + + title = data.get("title") + description = data.get("description") + reward_enabled = data.get("reward_enabled", False) + reward_amount = data.get("reward_amount_kopeks", 0) + + poll = await create_poll( + db, + title=title, + description=description, + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount, + created_by=db_user.id, + 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), + parse_mode="HTML", + ) + return + + lines = [line.strip() for line in message.text.splitlines() if line.strip()] + if len(lines) < 3: + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) + ) + 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), + parse_mode="HTML", + ) + + +async def _render_poll_details(poll: Poll, language: str) -> str: + texts = get_texts(language) + lines = [f"🗳️ {html.escape(poll.title)}"] + if poll.description: + lines.append(poll.description) + + lines.append(_format_reward_text(poll, language)) + lines.append( + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format( + count=len(poll.questions) + ) + ) + + if poll.questions: + lines.append("") + lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "Вопросы:")) + for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1): + lines.append(f"{idx}. {html.escape(question.text)}") + for option in sorted(question.options, key=lambda o: o.order): + lines.append( + texts.t("ADMIN_POLLS_OPTION_BULLET", " • {option}").format( + option=html.escape(option.text) + ) + ) + + return "\n".join(lines) + + +@admin_required +@error_handler +async def show_poll_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + text = await _render_poll_details(poll, db_user.language) + await callback.message.edit_text( + text, + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_send( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"), + reply_markup=_build_target_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_custom_target_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + await callback.message.edit_text( + get_texts(db_user.language).t( + "ADMIN_POLLS_CUSTOM_PROMPT", + "Выберите дополнительный критерий аудитории:", + ), + reply_markup=_build_custom_target_keyboard(poll_id, db_user.language), + ) + await callback.answer() + + +async def _show_send_confirmation( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + poll_id: int, + target: str, + user_count: int, +): + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + audience_name = get_target_display_name(target) + texts = get_texts(db_user.language) + confirmation_text = texts.t( + "ADMIN_POLLS_SEND_CONFIRM", + "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", + ).format(title=poll.title, audience=audience_name, count=user_count) + + await callback.message.edit_text( + confirmation_text, + reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, target = payload.split(":", 1) + poll_id = int(poll_id_str) + + user_count = await get_target_users_count(db, target) + await _show_send_confirmation(callback, db_user, db, poll_id, target, user_count) + + +@admin_required +@error_handler +async def select_custom_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, criteria = payload.split(":", 1) + poll_id = int(poll_id_str) + + user_count = await get_custom_users_count(db, criteria) + await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count) + + +@admin_required +@error_handler +async def confirm_poll_send( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, target = payload.split(":", 1) + poll_id = int(poll_id_str) + + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + if target.startswith("custom_"): + users = await get_custom_users(db, target.replace("custom_", "")) + else: + users = await get_target_users(db, target) + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), + parse_mode="HTML", + ) + + result = await send_poll_to_users(callback.bot, db, poll, users) + + result_text = texts.t( + "ADMIN_POLLS_SEND_RESULT", + "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", + ).format(**result) + + await callback.message.edit_text( + result_text, + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + stats = await get_poll_statistics(db, poll_id) + texts = get_texts(db_user.language) + + reward_sum = settings.format_price(stats["reward_sum_kopeks"]) + lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 Статистика опроса"), ""] + lines.append( + texts.t( + "ADMIN_POLLS_STATS_OVERVIEW", + "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", + ).format( + total=stats["total_responses"], + completed=stats["completed_responses"], + reward=reward_sum, + ) + ) + + for question in stats["questions"]: + lines.append("") + lines.append(f"{html.escape(question['text'])}") + for option in question["options"]: + lines.append( + texts.t( + "ADMIN_POLLS_STATS_OPTION_LINE", + "• {option}: {count}", + ).format(option=html.escape(option["text"]), count=option["count"]) + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_poll_delete( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CONFIRM_DELETE", + "Вы уверены, что хотите удалить опрос «{title}»?", + ).format(title=poll.title), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"poll_delete_confirm:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_view:{poll_id}", + ) + ], + ] + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_poll_handler( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + success = await delete_poll(db, poll_id) + texts = get_texts(db_user.language) + + if success: + await callback.message.edit_text( + texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), + reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ) + else: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_polls_panel, F.data == "admin_polls") + dp.callback_query.register(start_poll_creation, F.data == "poll_create") + dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:")) + dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:")) + dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:")) + dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:")) + dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:")) + dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:")) + dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:")) + dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:")) + dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:")) + + dp.message.register(process_poll_title, PollCreationStates.waiting_for_title) + dp.message.register(process_poll_description, PollCreationStates.waiting_for_description) + dp.message.register(process_poll_reward, PollCreationStates.waiting_for_reward) + dp.message.register(process_poll_question, PollCreationStates.waiting_for_questions) diff --git a/app/handlers/polls.py b/app/handlers/polls.py new file mode 100644 index 00000000..2572b38b --- /dev/null +++ b/app/handlers/polls.py @@ -0,0 +1,197 @@ +import asyncio +import logging +from datetime import datetime + +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.poll import ( + get_poll_response_by_id, + record_poll_answer, +) +from app.database.models import PollQuestion, User +from app.localization.texts import get_texts +from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll + +logger = logging.getLogger(__name__) + + +async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None: + try: + await asyncio.sleep(delay) + await bot.delete_message(chat_id, message_id) + except Exception as error: # pragma: no cover - cleanup best effort + logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error) + + +async def _render_question_text( + poll_title: str, + question: PollQuestion, + current_index: int, + total: int, + language: str, +) -> str: + texts = get_texts(language) + header = texts.t("POLL_QUESTION_HEADER", "Вопрос {current}/{total}").format( + current=current_index, + total=total, + ) + lines = [f"🗳️ {poll_title}", "", header, "", question.text] + return "\n".join(lines) + + +def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: + buttons: list[list[types.InlineKeyboardButton]] = [] + for option in sorted(question.options, key=lambda o: o.order): + buttons.append( + [ + types.InlineKeyboardButton( + text=option.text, + callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}", + ) + ] + ) + return types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +async def handle_poll_start( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + try: + response_id = int(callback.data.split(":")[1]) + except (IndexError, ValueError): + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + response = await get_poll_response_by_id(db, response_id) + if not response or response.user_id != db_user.id: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + + if response.completed_at: + await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + return + + if not response.poll or not response.poll.questions: + await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True) + return + + if not response.started_at: + response.started_at = datetime.utcnow() + await db.commit() + + index, question = await get_next_question(response) + if not question: + await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True) + return + + question_text = await _render_question_text( + response.poll.title, + question, + index, + len(response.poll.questions), + db_user.language, + ) + + await callback.message.edit_text( + question_text, + reply_markup=_build_options_keyboard(response.id, question), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_poll_answer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + try: + _, response_id, question_id, option_id = callback.data.split(":", 3) + response_id = int(response_id) + question_id = int(question_id) + option_id = int(option_id) + except (ValueError, IndexError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + response = await get_poll_response_by_id(db, response_id) + texts = get_texts(db_user.language) + + if not response or response.user_id != db_user.id: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + if not response.poll: + await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + return + + if response.completed_at: + await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + return + + question = next((q for q in response.poll.questions if q.id == question_id), None) + if not question: + await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True) + return + + option = await get_question_option(question, option_id) + if not option: + await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True) + return + + await record_poll_answer( + db, + response_id=response.id, + question_id=question.id, + option_id=option.id, + ) + + response = await get_poll_response_by_id(db, response.id) + index, next_question = await get_next_question(response) + + if next_question: + question_text = await _render_question_text( + response.poll.title, + next_question, + index, + len(response.poll.questions), + db_user.language, + ) + await callback.message.edit_text( + question_text, + reply_markup=_build_options_keyboard(response.id, next_question), + parse_mode="HTML", + ) + await callback.answer() + return + + response.completed_at = datetime.utcnow() + await db.commit() + + reward_amount = await reward_user_for_poll(db, response) + + thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")] + if reward_amount: + thanks_lines.append( + texts.t( + "POLL_REWARD_GRANTED", + "Награда {amount} зачислена на ваш баланс.", + ).format(amount=settings.format_price(reward_amount)) + ) + + await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + asyncio.create_task( + _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:")) + dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 75e72bc0..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"), + callback_data="admin_polls", + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index eee72fe3..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,5 +1375,50 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", + "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls", + "ADMIN_POLLS_CREATE": "➕ Create poll", + "ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}", + "ADMIN_POLLS_REWARD_DISABLED": "Reward disabled", + "ADMIN_POLLS_SEND": "📤 Send", + "ADMIN_POLLS_STATS": "📊 Stats", + "ADMIN_POLLS_DELETE": "🗑️ Delete", + "ADMIN_POLLS_BACK": "⬅️ Back to list", + "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters", + "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send", + "ADMIN_POLLS_LIST_TITLE": "🗳️ Polls", + "ADMIN_POLLS_LIST_EMPTY": "No polls yet.", + "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", + "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Create poll\n\nEnter poll title:", + "ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.", + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.", + "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}", + "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.", + "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.", + "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.", + "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.", + "ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}", + "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.", + "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.", + "ADMIN_POLLS_QUESTION_LIST_HEADER": "Questions:", + "ADMIN_POLLS_OPTION_BULLET": " • {option}", + "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:", + "ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:", + "ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}", + "ADMIN_POLLS_SENDING": "📤 Sending poll...", + "ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}", + "ADMIN_POLLS_STATS_HEADER": "📊 Poll statistics", + "ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}", + "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", + "ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?", + "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", + "POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.", + "POLL_INVITATION_START": "Tap the button below to answer the poll.", + "POLL_START_BUTTON": "📝 Take the poll", + "POLL_QUESTION_HEADER": "Question {current}/{total}", + "POLL_ALREADY_COMPLETED": "You have already completed this poll.", + "POLL_EMPTY": "Poll is not available yet.", + "POLL_ERROR": "Unable to process the poll. Please try again later.", + "POLL_COMPLETED": "🙏 Thanks for completing the poll!", + "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ca6da8ca..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,5 +1375,50 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", + "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы", + "ADMIN_POLLS_CREATE": "➕ Создать опрос", + "ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}", + "ADMIN_POLLS_REWARD_DISABLED": "Награда отключена", + "ADMIN_POLLS_SEND": "📤 Отправить", + "ADMIN_POLLS_STATS": "📊 Статистика", + "ADMIN_POLLS_DELETE": "🗑️ Удалить", + "ADMIN_POLLS_BACK": "⬅️ К списку", + "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям", + "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить", + "ADMIN_POLLS_LIST_TITLE": "🗳️ Опросы", + "ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.", + "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", + "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Создание опроса\n\nВведите заголовок опроса:", + "ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.", + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}", + "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.", + "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.", + "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.", + "ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", + "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.", + "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + "ADMIN_POLLS_QUESTION_LIST_HEADER": "Вопросы:", + "ADMIN_POLLS_OPTION_BULLET": " • {option}", + "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:", + "ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:", + "ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", + "ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...", + "ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", + "ADMIN_POLLS_STATS_HEADER": "📊 Статистика опроса", + "ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", + "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", + "ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?", + "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", + "POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.", + "POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.", + "POLL_START_BUTTON": "📝 Пройти опрос", + "POLL_QUESTION_HEADER": "Вопрос {current}/{total}", + "POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.", + "POLL_EMPTY": "Опрос пока недоступен.", + "POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.", + "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", + "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." } diff --git a/app/services/poll_service.py b/app/services/poll_service.py new file mode 100644 index 00000000..5e8b6187 --- /dev/null +++ b/app/services/poll_service.py @@ -0,0 +1,179 @@ +import asyncio +import logging +from typing import Iterable + +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import add_user_balance +from app.database.models import ( + Poll, + PollOption, + PollQuestion, + PollResponse, + TransactionType, + User, +) +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) + + lines: list[str] = [f"🗳️ {poll.title}"] + if poll.description: + lines.append(poll.description) + + if poll.reward_enabled and poll.reward_amount_kopeks > 0: + reward_line = texts.t( + "POLL_INVITATION_REWARD", + "🎁 За участие вы получите {amount}.", + ).format(amount=settings.format_price(poll.reward_amount_kopeks)) + lines.append(reward_line) + + lines.append( + texts.t( + "POLL_INVITATION_START", + "Нажмите кнопку ниже, чтобы пройти опрос.", + ) + ) + + return "\n\n".join(lines) + + +def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"), + callback_data=f"poll_start:{response_id}", + ) + ] + ] + ) + + +async def send_poll_to_users( + bot: Bot, + db: AsyncSession, + poll: Poll, + users: Iterable[User], +) -> dict: + sent = 0 + failed = 0 + skipped = 0 + + for index, user in enumerate(users, start=1): + existing_response = await db.execute( + select(PollResponse.id).where( + and_( + PollResponse.poll_id == poll.id, + PollResponse.user_id == user.id, + ) + ) + ) + if existing_response.scalar_one_or_none(): + skipped += 1 + continue + + response = PollResponse( + poll_id=poll.id, + user_id=user.id, + ) + db.add(response) + + try: + await db.flush() + + text = _build_poll_invitation_text(poll, user) + keyboard = build_start_keyboard(response.id, user.language) + + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=keyboard, + parse_mode="HTML", + disable_web_page_preview=True, + ) + + await db.commit() + sent += 1 + + if index % 20 == 0: + await asyncio.sleep(1) + except Exception as error: # pragma: no cover - defensive logging + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll.id, + user.telegram_id, + error, + ) + await db.rollback() + + return { + "sent": sent, + "failed": failed, + "skipped": skipped, + "total": sent + failed + skipped, + } + + +async def reward_user_for_poll( + db: AsyncSession, + response: PollResponse, +) -> int: + poll = response.poll + if not poll.reward_enabled or poll.reward_amount_kopeks <= 0: + return 0 + + if response.reward_given: + return response.reward_amount_kopeks + + user = response.user + description = f"Награда за участие в опросе \"{poll.title}\"" + + success = await add_user_balance( + db, + user, + poll.reward_amount_kopeks, + description, + transaction_type=TransactionType.POLL_REWARD, + ) + + if not success: + return 0 + + response.reward_given = True + response.reward_amount_kopeks = poll.reward_amount_kopeks + await db.commit() + + return poll.reward_amount_kopeks + + +async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]: + if not response.poll or not response.poll.questions: + return None, None + + answered_question_ids = {answer.question_id for answer in response.answers} + ordered_questions = sorted(response.poll.questions, key=lambda q: q.order) + + for index, question in enumerate(ordered_questions, start=1): + if question.id not in answered_question_ids: + return index, question + + return None, None + + +async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None: + for option in question.options: + if option.id == option_id: + return option + return None diff --git a/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py new file mode 100644 index 00000000..3b240735 --- /dev/null +++ b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py @@ -0,0 +1,155 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "9f0f2d5a1c7b" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "polls", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "reward_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index("ix_polls_id", "polls", ["id"]) + + op.create_table( + "poll_questions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), + ) + op.create_index("ix_poll_questions_id", "poll_questions", ["id"]) + op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"]) + + op.create_table( + "poll_options", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), + ) + op.create_index("ix_poll_options_id", "poll_options", ["id"]) + op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"]) + + op.create_table( + "poll_responses", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "sent_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column( + "reward_given", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + op.create_index("ix_poll_responses_id", "poll_responses", ["id"]) + op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"]) + op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"]) + + op.create_table( + "poll_answers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("response_id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("option_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"), + sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), + ) + op.create_index("ix_poll_answers_id", "poll_answers", ["id"]) + op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"]) + op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"]) + + +def downgrade() -> None: + op.drop_index("ix_poll_answers_question_id", table_name="poll_answers") + op.drop_index("ix_poll_answers_response_id", table_name="poll_answers") + op.drop_index("ix_poll_answers_id", table_name="poll_answers") + op.drop_table("poll_answers") + + op.drop_index("ix_poll_responses_user_id", table_name="poll_responses") + op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses") + op.drop_index("ix_poll_responses_id", table_name="poll_responses") + op.drop_table("poll_responses") + + op.drop_index("ix_poll_options_question_id", table_name="poll_options") + op.drop_index("ix_poll_options_id", table_name="poll_options") + op.drop_table("poll_options") + + op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions") + op.drop_index("ix_poll_questions_id", table_name="poll_questions") + op.drop_table("poll_questions") + + op.drop_index("ix_polls_id", table_name="polls") + op.drop_table("polls") From 7b4cbb27b7fd72635b024b0be06fd6f9f8b1fc8c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:37:40 +0300 Subject: [PATCH 03/31] Revert "Add poll management and delivery system" --- app/bot.py | 16 +- app/database/crud/poll.py | 265 ------ app/database/crud/user.py | 21 +- app/database/models.py | 116 +-- app/handlers/admin/polls.py | 825 ------------------ app/handlers/polls.py | 197 ----- app/keyboards/admin.py | 6 - app/localization/locales/en.json | 47 +- app/localization/locales/ru.json | 47 +- app/services/poll_service.py | 179 ---- .../versions/9f0f2d5a1c7b_add_polls_tables.py | 155 ---- 11 files changed, 18 insertions(+), 1856 deletions(-) delete mode 100644 app/database/crud/poll.py delete mode 100644 app/handlers/admin/polls.py delete mode 100644 app/handlers/polls.py delete mode 100644 app/services/poll_service.py delete mode 100644 migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index 826d8d6e..f21b7534 100644 --- a/app/bot.py +++ b/app/bot.py @@ -16,18 +16,9 @@ from app.services.maintenance_service import maintenance_service from app.utils.cache import cache from app.handlers import ( - start, - menu, - subscription, - balance, - promocode, - referral, - support, - server_status, - common, - tickets, + start, menu, subscription, balance, promocode, + referral, support, server_status, common, tickets ) -from app.handlers import polls as user_polls from app.handlers import simple_subscription from app.handlers.admin import ( main as admin_main, @@ -40,7 +31,6 @@ from app.handlers.admin import ( rules as admin_rules, remnawave as admin_remnawave, statistics as admin_statistics, - polls as admin_polls, servers as admin_servers, maintenance as admin_maintenance, promo_groups as admin_promo_groups, @@ -155,7 +145,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) - admin_polls.register_handlers(dp) admin_promo_groups.register_handlers(dp) admin_campaigns.register_handlers(dp) admin_promo_offers.register_handlers(dp) @@ -174,7 +163,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_faq.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) - user_polls.register_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") logger.info("⚡ Зарегистрированы обработчики простой покупки") diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py deleted file mode 100644 index 5b3b35ad..00000000 --- a/app/database/crud/poll.py +++ /dev/null @@ -1,265 +0,0 @@ -import logging -from typing import Iterable, Sequence - -from sqlalchemy import and_, delete, func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, -) - -logger = logging.getLogger(__name__) - - -async def create_poll( - db: AsyncSession, - *, - title: str, - description: str | None, - reward_enabled: bool, - reward_amount_kopeks: int, - created_by: int | None, - questions: Sequence[dict[str, Iterable[str]]], -) -> Poll: - poll = Poll( - title=title, - description=description, - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0, - created_by=created_by, - ) - db.add(poll) - await db.flush() - - for order, question_data in enumerate(questions, start=1): - question_text = question_data.get("text", "").strip() - if not question_text: - continue - - question = PollQuestion( - poll_id=poll.id, - text=question_text, - order=order, - ) - db.add(question) - await db.flush() - - for option_order, option_text in enumerate(question_data.get("options", []), start=1): - option_text = option_text.strip() - if not option_text: - continue - option = PollOption( - question_id=question.id, - text=option_text, - order=option_order, - ) - db.add(option) - - await db.commit() - await db.refresh( - poll, - attribute_names=["questions"], - ) - return poll - - -async def list_polls(db: AsyncSession) -> list[Poll]: - result = await db.execute( - select(Poll) - .options( - selectinload(Poll.questions).options(selectinload(PollQuestion.options)) - ) - .order_by(Poll.created_at.desc()) - ) - return result.scalars().all() - - -async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None: - result = await db.execute( - select(Poll) - .options( - selectinload(Poll.questions).options(selectinload(PollQuestion.options)), - selectinload(Poll.responses), - ) - .where(Poll.id == poll_id) - ) - return result.scalar_one_or_none() - - -async def delete_poll(db: AsyncSession, poll_id: int) -> bool: - poll = await db.get(Poll, poll_id) - if not poll: - return False - - await db.delete(poll) - await db.commit() - logger.info("🗑️ Удалён опрос %s", poll_id) - return True - - -async def create_poll_response( - db: AsyncSession, - poll_id: int, - user_id: int, -) -> PollResponse: - result = await db.execute( - select(PollResponse) - .where( - and_( - PollResponse.poll_id == poll_id, - PollResponse.user_id == user_id, - ) - ) - ) - response = result.scalar_one_or_none() - if response: - return response - - response = PollResponse( - poll_id=poll_id, - user_id=user_id, - ) - db.add(response) - await db.commit() - await db.refresh(response) - return response - - -async def get_poll_response_by_id( - db: AsyncSession, - response_id: int, -) -> PollResponse | None: - result = await db.execute( - select(PollResponse) - .options( - selectinload(PollResponse.poll) - .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))), - selectinload(PollResponse.answers), - selectinload(PollResponse.user), - ) - .where(PollResponse.id == response_id) - ) - return result.scalar_one_or_none() - - -async def record_poll_answer( - db: AsyncSession, - *, - response_id: int, - question_id: int, - option_id: int, -) -> PollAnswer: - result = await db.execute( - select(PollAnswer) - .where( - and_( - PollAnswer.response_id == response_id, - PollAnswer.question_id == question_id, - ) - ) - ) - answer = result.scalar_one_or_none() - if answer: - answer.option_id = option_id - await db.commit() - await db.refresh(answer) - return answer - - answer = PollAnswer( - response_id=response_id, - question_id=question_id, - option_id=option_id, - ) - db.add(answer) - await db.commit() - await db.refresh(answer) - return answer - - -async def reset_poll_answers(db: AsyncSession, response_id: int) -> None: - await db.execute( - delete(PollAnswer).where(PollAnswer.response_id == response_id) - ) - await db.commit() - - -async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: - totals_result = await db.execute( - select( - func.count(PollResponse.id), - func.count(PollResponse.completed_at), - func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0), - ).where(PollResponse.poll_id == poll_id) - ) - total_responses, completed_responses, reward_sum = totals_result.one() - - option_counts_result = await db.execute( - select( - PollQuestion.id, - PollQuestion.text, - PollQuestion.order, - PollOption.id, - PollOption.text, - PollOption.order, - func.count(PollAnswer.id), - ) - .join(PollOption, PollOption.question_id == PollQuestion.id) - .outerjoin( - PollAnswer, - and_( - PollAnswer.question_id == PollQuestion.id, - PollAnswer.option_id == PollOption.id, - ), - ) - .where(PollQuestion.poll_id == poll_id) - .group_by( - PollQuestion.id, - PollQuestion.text, - PollQuestion.order, - PollOption.id, - PollOption.text, - PollOption.order, - ) - .order_by(PollQuestion.order.asc(), PollOption.order.asc()) - ) - - questions_map: dict[int, dict] = {} - for ( - question_id, - question_text, - question_order, - option_id, - option_text, - option_order, - answer_count, - ) in option_counts_result: - question_entry = questions_map.setdefault( - question_id, - { - "id": question_id, - "text": question_text, - "order": question_order, - "options": [], - }, - ) - question_entry["options"].append( - { - "id": option_id, - "text": option_text, - "count": answer_count, - } - ) - - questions = sorted(questions_map.values(), key=lambda item: item["order"]) - - return { - "total_responses": total_responses, - "completed_responses": completed_responses, - "reward_sum_kopeks": reward_sum, - "questions": questions, - } diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 67112086..e9dea8c2 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -219,8 +219,7 @@ async def add_user_balance( amount_kopeks: int, description: str = "Пополнение баланса", create_transaction: bool = True, - transaction_type: TransactionType = TransactionType.DEPOSIT, - bot = None + bot = None ) -> bool: try: old_balance = user.balance_kopeks @@ -229,11 +228,12 @@ async def add_user_balance( if create_transaction: from app.database.crud.transaction import create_transaction as create_trans - + from app.database.models import TransactionType + await create_trans( db=db, user_id=user.id, - type=transaction_type, + type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, description=description ) @@ -253,10 +253,9 @@ async def add_user_balance( async def add_user_balance_by_id( db: AsyncSession, - telegram_id: int, + telegram_id: int, amount_kopeks: int, - description: str = "Пополнение баланса", - transaction_type: TransactionType = TransactionType.DEPOSIT, + description: str = "Пополнение баланса" ) -> bool: try: user = await get_user_by_telegram_id(db, telegram_id) @@ -264,13 +263,7 @@ async def add_user_balance_by_id( logger.error(f"Пользователь с telegram_id {telegram_id} не найден") return False - return await add_user_balance( - db, - user, - amount_kopeks, - description, - transaction_type=transaction_type, - ) + return await add_user_balance(db, user, amount_kopeks, description) except Exception as e: logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}") diff --git a/app/database/models.py b/app/database/models.py index b4e9bc80..d296aba9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -58,12 +58,11 @@ class SubscriptionStatus(Enum): class TransactionType(Enum): - DEPOSIT = "deposit" - WITHDRAWAL = "withdrawal" - SUBSCRIPTION_PAYMENT = "subscription_payment" - REFUND = "refund" - REFERRAL_REWARD = "referral_reward" - POLL_REWARD = "poll_reward" + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + SUBSCRIPTION_PAYMENT = "subscription_payment" + REFUND = "refund" + REFERRAL_REWARD = "referral_reward" class PromoCodeType(Enum): @@ -531,7 +530,6 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") - poll_responses = relationship("PollResponse", back_populates="user") @property def balance_rubles(self) -> float: @@ -1063,9 +1061,9 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) - target_type = Column(String(100), nullable=False) + target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) has_media = Column(Boolean, default=False) media_type = Column(String(20), nullable=True) @@ -1081,106 +1079,6 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") - -class Poll(Base): - __tablename__ = "polls" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - reward_enabled = Column(Boolean, nullable=False, default=False) - reward_amount_kopeks = Column(Integer, nullable=False, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime, default=func.now(), nullable=False) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False) - - creator = relationship("User", backref="created_polls", foreign_keys=[created_by]) - questions = relationship( - "PollQuestion", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollQuestion.order", - ) - responses = relationship( - "PollResponse", - back_populates="poll", - cascade="all, delete-orphan", - ) - - -class PollQuestion(Base): - __tablename__ = "poll_questions" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) - text = Column(Text, nullable=False) - order = Column(Integer, nullable=False, default=0) - - poll = relationship("Poll", back_populates="questions") - options = relationship( - "PollOption", - back_populates="question", - cascade="all, delete-orphan", - order_by="PollOption.order", - ) - answers = relationship("PollAnswer", back_populates="question") - - -class PollOption(Base): - __tablename__ = "poll_options" - - id = Column(Integer, primary_key=True, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) - text = Column(Text, nullable=False) - order = Column(Integer, nullable=False, default=0) - - question = relationship("PollQuestion", back_populates="options") - answers = relationship("PollAnswer", back_populates="option") - - -class PollResponse(Base): - __tablename__ = "poll_responses" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - sent_at = Column(DateTime, default=func.now(), nullable=False) - started_at = Column(DateTime, nullable=True) - completed_at = Column(DateTime, nullable=True) - reward_given = Column(Boolean, nullable=False, default=False) - reward_amount_kopeks = Column(Integer, nullable=False, default=0) - - poll = relationship("Poll", back_populates="responses") - user = relationship("User", back_populates="poll_responses") - answers = relationship( - "PollAnswer", - back_populates="response", - cascade="all, delete-orphan", - ) - - __table_args__ = ( - UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - - -class PollAnswer(Base): - __tablename__ = "poll_answers" - - id = Column(Integer, primary_key=True, index=True) - response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) - option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True) - created_at = Column(DateTime, default=func.now(), nullable=False) - - response = relationship("PollResponse", back_populates="answers") - question = relationship("PollQuestion", back_populates="answers") - option = relationship("PollOption", back_populates="answers") - - __table_args__ = ( - UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), - ) - - class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py deleted file mode 100644 index 1220cae4..00000000 --- a/app/handlers/admin/polls.py +++ /dev/null @@ -1,825 +0,0 @@ -import html -import logging -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from typing import Optional - -from aiogram import Dispatcher, F, types -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State, StatesGroup -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.poll import ( - create_poll, - delete_poll, - get_poll_by_id, - get_poll_statistics, - list_polls, -) -from app.database.models import Poll, User -from app.handlers.admin.messages import ( - get_custom_users, - get_custom_users_count, - get_target_display_name, - get_target_users, - get_target_users_count, -) -from app.keyboards.admin import get_admin_communications_submenu_keyboard -from app.localization.texts import get_texts -from app.services.poll_service import send_poll_to_users -from app.utils.decorators import admin_required, error_handler -from app.utils.validators import get_html_help_text, validate_html_tags - -logger = logging.getLogger(__name__) - - -class PollCreationStates(StatesGroup): - waiting_for_title = State() - waiting_for_description = State() - waiting_for_reward = State() - waiting_for_questions = State() - - -def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: - texts = get_texts(language) - keyboard: list[list[types.InlineKeyboardButton]] = [] - - for poll in polls[:10]: - keyboard.append( - [ - types.InlineKeyboardButton( - text=f"🗳️ {poll.title[:40]}", - callback_data=f"poll_view:{poll.id}", - ) - ] - ) - - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), - callback_data="poll_create", - ) - ] - ) - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_communications", - ) - ] - ) - - return types.InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def _format_reward_text(poll: Poll, language: str) -> str: - texts = get_texts(language) - if poll.reward_enabled and poll.reward_amount_kopeks > 0: - return texts.t( - "ADMIN_POLLS_REWARD_ENABLED", - "Награда: {amount}", - ).format(amount=settings.format_price(poll.reward_amount_kopeks)) - return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена") - - -def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: - texts = get_texts(language) - return types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"), - callback_data=f"poll_send:{poll_id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"), - callback_data=f"poll_stats:{poll_id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"poll_delete:{poll_id}", - ) - ], - [types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")], - ] - ) - - -def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: - texts = get_texts(language) - return types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data=f"poll_target:{poll_id}:all", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data=f"poll_target:{poll_id}:active", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data=f"poll_target:{poll_id}:trial", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data=f"poll_target:{poll_id}:no", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data=f"poll_target:{poll_id}:expiring", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data=f"poll_target:{poll_id}:expired", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data=f"poll_target:{poll_id}:active_zero", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data=f"poll_target:{poll_id}:trial_zero", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"), - callback_data=f"poll_custom_menu:{poll_id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"poll_view:{poll_id}", - ) - ], - ] - ) - - -def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: - texts = get_texts(language) - return types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"), - callback_data=f"poll_custom_target:{poll_id}:today", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"), - callback_data=f"poll_custom_target:{poll_id}:week", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"), - callback_data=f"poll_custom_target:{poll_id}:month", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), - callback_data=f"poll_custom_target:{poll_id}:active_today", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), - callback_data=f"poll_custom_target:{poll_id}:inactive_week", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), - callback_data=f"poll_custom_target:{poll_id}:inactive_month", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), - callback_data=f"poll_custom_target:{poll_id}:referrals", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), - callback_data=f"poll_custom_target:{poll_id}:direct", - ), - ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")], - ] - ) - - -def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) -> types.InlineKeyboardMarkup: - texts = get_texts(language) - return types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"), - callback_data=f"poll_send_confirm:{poll_id}:{target}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"poll_send:{poll_id}", - ) - ], - ] - ) - - -@admin_required -@error_handler -async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): - polls = await list_polls(db) - texts = get_texts(db_user.language) - - lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ Опросы"), ""] - if not polls: - lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет.")) - else: - for poll in polls[:10]: - reward = _format_reward_text(poll, db_user.language) - lines.append( - f"• {html.escape(poll.title)} — " - f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n" - f" {reward}" - ) - - await callback.message.edit_text( - "\n".join(lines), - reply_markup=_build_polls_keyboard(polls, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_poll_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - 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( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_poll_title( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) - return - - title = message.text.strip() - if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") - return - - await state.update_data(title=title) - await state.set_state(PollCreationStates.waiting_for_description) - - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) - - -@admin_required -@error_handler -async def process_poll_description( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) - return - - description: Optional[str] - if message.text == "/skip": - description = None - else: - description = message.text.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) - ) - 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 чтобы отключить награду.", - ) - ) - - -def _parse_reward_amount(message_text: str) -> int | None: - normalized = message_text.replace(" ", "").replace(",", ".") - try: - value = Decimal(normalized) - except InvalidOperation: - return None - - if value < 0: - value = Decimal(0) - - kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) - return max(0, kopeks) - - -@admin_required -@error_handler -async def process_poll_reward( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) - return - - reward_kopeks = _parse_reward_amount(message.text) - if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) - return - - reward_enabled = reward_kopeks > 0 - await state.update_data( - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_kopeks, - ) - await state.set_state(PollCreationStates.waiting_for_questions) - - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) - - -@admin_required -@error_handler -async def process_poll_question( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - texts = get_texts(db_user.language) - if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) - return - - if message.text == "/done": - data = await state.get_data() - questions = data.get("questions", []) - if not questions: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), - ) - return - - title = data.get("title") - description = data.get("description") - reward_enabled = data.get("reward_enabled", False) - reward_amount = data.get("reward_amount_kopeks", 0) - - poll = await create_poll( - db, - title=title, - description=description, - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_amount, - created_by=db_user.id, - 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), - parse_mode="HTML", - ) - return - - lines = [line.strip() for line in message.text.splitlines() if line.strip()] - if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - ) - 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), - parse_mode="HTML", - ) - - -async def _render_poll_details(poll: Poll, language: str) -> str: - texts = get_texts(language) - lines = [f"🗳️ {html.escape(poll.title)}"] - if poll.description: - lines.append(poll.description) - - lines.append(_format_reward_text(poll, language)) - lines.append( - texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format( - count=len(poll.questions) - ) - ) - - if poll.questions: - lines.append("") - lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "Вопросы:")) - for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1): - lines.append(f"{idx}. {html.escape(question.text)}") - for option in sorted(question.options, key=lambda o: o.order): - lines.append( - texts.t("ADMIN_POLLS_OPTION_BULLET", " • {option}").format( - option=html.escape(option.text) - ) - ) - - return "\n".join(lines) - - -@admin_required -@error_handler -async def show_poll_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - text = await _render_poll_details(poll, db_user.language) - await callback.message.edit_text( - text, - reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_poll_send( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - texts = get_texts(db_user.language) - await callback.message.edit_text( - texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"), - reply_markup=_build_target_keyboard(poll.id, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_custom_target_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - await callback.message.edit_text( - get_texts(db_user.language).t( - "ADMIN_POLLS_CUSTOM_PROMPT", - "Выберите дополнительный критерий аудитории:", - ), - reply_markup=_build_custom_target_keyboard(poll_id, db_user.language), - ) - await callback.answer() - - -async def _show_send_confirmation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - poll_id: int, - target: str, - user_count: int, -): - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - audience_name = get_target_display_name(target) - texts = get_texts(db_user.language) - confirmation_text = texts.t( - "ADMIN_POLLS_SEND_CONFIRM", - "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", - ).format(title=poll.title, audience=audience_name, count=user_count) - - await callback.message.edit_text( - confirmation_text, - reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def select_poll_target( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - _, payload = callback.data.split(":", 1) - poll_id_str, target = payload.split(":", 1) - poll_id = int(poll_id_str) - - user_count = await get_target_users_count(db, target) - await _show_send_confirmation(callback, db_user, db, poll_id, target, user_count) - - -@admin_required -@error_handler -async def select_custom_poll_target( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - _, payload = callback.data.split(":", 1) - poll_id_str, criteria = payload.split(":", 1) - poll_id = int(poll_id_str) - - user_count = await get_custom_users_count(db, criteria) - await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count) - - -@admin_required -@error_handler -async def confirm_poll_send( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - _, payload = callback.data.split(":", 1) - poll_id_str, target = payload.split(":", 1) - poll_id = int(poll_id_str) - - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - if target.startswith("custom_"): - users = await get_custom_users(db, target.replace("custom_", "")) - else: - users = await get_target_users(db, target) - - texts = get_texts(db_user.language) - await callback.message.edit_text( - texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), - parse_mode="HTML", - ) - - result = await send_poll_to_users(callback.bot, db, poll, users) - - result_text = texts.t( - "ADMIN_POLLS_SEND_RESULT", - "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", - ).format(**result) - - await callback.message.edit_text( - result_text, - reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - stats = await get_poll_statistics(db, poll_id) - texts = get_texts(db_user.language) - - reward_sum = settings.format_price(stats["reward_sum_kopeks"]) - lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 Статистика опроса"), ""] - lines.append( - texts.t( - "ADMIN_POLLS_STATS_OVERVIEW", - "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", - ).format( - total=stats["total_responses"], - completed=stats["completed_responses"], - reward=reward_sum, - ) - ) - - for question in stats["questions"]: - lines.append("") - lines.append(f"{html.escape(question['text'])}") - for option in question["options"]: - lines.append( - texts.t( - "ADMIN_POLLS_STATS_OPTION_LINE", - "• {option}: {count}", - ).format(option=html.escape(option["text"]), count=option["count"]) - ) - - await callback.message.edit_text( - "\n".join(lines), - reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def confirm_poll_delete( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - poll = await get_poll_by_id(db, poll_id) - if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - texts = get_texts(db_user.language) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CONFIRM_DELETE", - "Вы уверены, что хотите удалить опрос «{title}»?", - ).format(title=poll.title), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"poll_delete_confirm:{poll_id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"poll_view:{poll_id}", - ) - ], - ] - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def delete_poll_handler( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - poll_id = int(callback.data.split(":")[1]) - success = await delete_poll(db, poll_id) - texts = get_texts(db_user.language) - - if success: - await callback.message.edit_text( - texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), - ) - else: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - await callback.answer() - - -def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_polls_panel, F.data == "admin_polls") - dp.callback_query.register(start_poll_creation, F.data == "poll_create") - dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:")) - dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:")) - dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:")) - dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:")) - dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:")) - dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:")) - dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:")) - dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:")) - dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:")) - - dp.message.register(process_poll_title, PollCreationStates.waiting_for_title) - dp.message.register(process_poll_description, PollCreationStates.waiting_for_description) - dp.message.register(process_poll_reward, PollCreationStates.waiting_for_reward) - dp.message.register(process_poll_question, PollCreationStates.waiting_for_questions) diff --git a/app/handlers/polls.py b/app/handlers/polls.py deleted file mode 100644 index 2572b38b..00000000 --- a/app/handlers/polls.py +++ /dev/null @@ -1,197 +0,0 @@ -import asyncio -import logging -from datetime import datetime - -from aiogram import Dispatcher, F, types -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.poll import ( - get_poll_response_by_id, - record_poll_answer, -) -from app.database.models import PollQuestion, User -from app.localization.texts import get_texts -from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll - -logger = logging.getLogger(__name__) - - -async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None: - try: - await asyncio.sleep(delay) - await bot.delete_message(chat_id, message_id) - except Exception as error: # pragma: no cover - cleanup best effort - logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error) - - -async def _render_question_text( - poll_title: str, - question: PollQuestion, - current_index: int, - total: int, - language: str, -) -> str: - texts = get_texts(language) - header = texts.t("POLL_QUESTION_HEADER", "Вопрос {current}/{total}").format( - current=current_index, - total=total, - ) - lines = [f"🗳️ {poll_title}", "", header, "", question.text] - return "\n".join(lines) - - -def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: - buttons: list[list[types.InlineKeyboardButton]] = [] - for option in sorted(question.options, key=lambda o: o.order): - buttons.append( - [ - types.InlineKeyboardButton( - text=option.text, - callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}", - ) - ] - ) - return types.InlineKeyboardMarkup(inline_keyboard=buttons) - - -async def handle_poll_start( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - try: - response_id = int(callback.data.split(":")[1]) - except (IndexError, ValueError): - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - response = await get_poll_response_by_id(db, response_id) - if not response or response.user_id != db_user.id: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - texts = get_texts(db_user.language) - - if response.completed_at: - await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) - return - - if not response.poll or not response.poll.questions: - await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True) - return - - if not response.started_at: - response.started_at = datetime.utcnow() - await db.commit() - - index, question = await get_next_question(response) - if not question: - await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True) - return - - question_text = await _render_question_text( - response.poll.title, - question, - index, - len(response.poll.questions), - db_user.language, - ) - - await callback.message.edit_text( - question_text, - reply_markup=_build_options_keyboard(response.id, question), - parse_mode="HTML", - ) - await callback.answer() - - -async def handle_poll_answer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - try: - _, response_id, question_id, option_id = callback.data.split(":", 3) - response_id = int(response_id) - question_id = int(question_id) - option_id = int(option_id) - except (ValueError, IndexError): - await callback.answer("❌ Некорректные данные", show_alert=True) - return - - response = await get_poll_response_by_id(db, response_id) - texts = get_texts(db_user.language) - - if not response or response.user_id != db_user.id: - await callback.answer("❌ Опрос не найден", show_alert=True) - return - - if not response.poll: - await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) - return - - if response.completed_at: - await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) - return - - question = next((q for q in response.poll.questions if q.id == question_id), None) - if not question: - await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True) - return - - option = await get_question_option(question, option_id) - if not option: - await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True) - return - - await record_poll_answer( - db, - response_id=response.id, - question_id=question.id, - option_id=option.id, - ) - - response = await get_poll_response_by_id(db, response.id) - index, next_question = await get_next_question(response) - - if next_question: - question_text = await _render_question_text( - response.poll.title, - next_question, - index, - len(response.poll.questions), - db_user.language, - ) - await callback.message.edit_text( - question_text, - reply_markup=_build_options_keyboard(response.id, next_question), - parse_mode="HTML", - ) - await callback.answer() - return - - response.completed_at = datetime.utcnow() - await db.commit() - - reward_amount = await reward_user_for_poll(db, response) - - thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")] - if reward_amount: - thanks_lines.append( - texts.t( - "POLL_REWARD_GRANTED", - "Награда {amount} зачислена на ваш баланс.", - ).format(amount=settings.format_price(reward_amount)) - ) - - await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") - asyncio.create_task( - _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) - ) - await callback.answer() - - -def register_handlers(dp: Dispatcher): - dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:")) - dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 0fad927c..75e72bc0 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -101,12 +101,6 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"), - callback_data="admin_polls", - ) - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0103e8e6..eee72fe3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,50 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", - "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls", - "ADMIN_POLLS_CREATE": "➕ Create poll", - "ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}", - "ADMIN_POLLS_REWARD_DISABLED": "Reward disabled", - "ADMIN_POLLS_SEND": "📤 Send", - "ADMIN_POLLS_STATS": "📊 Stats", - "ADMIN_POLLS_DELETE": "🗑️ Delete", - "ADMIN_POLLS_BACK": "⬅️ Back to list", - "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters", - "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send", - "ADMIN_POLLS_LIST_TITLE": "🗳️ Polls", - "ADMIN_POLLS_LIST_EMPTY": "No polls yet.", - "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", - "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Create poll\n\nEnter poll title:", - "ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.", - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.", - "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}", - "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.", - "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.", - "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.", - "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.", - "ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}", - "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.", - "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.", - "ADMIN_POLLS_QUESTION_LIST_HEADER": "Questions:", - "ADMIN_POLLS_OPTION_BULLET": " • {option}", - "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:", - "ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:", - "ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}", - "ADMIN_POLLS_SENDING": "📤 Sending poll...", - "ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}", - "ADMIN_POLLS_STATS_HEADER": "📊 Poll statistics", - "ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}", - "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", - "ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?", - "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", - "POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.", - "POLL_INVITATION_START": "Tap the button below to answer the poll.", - "POLL_START_BUTTON": "📝 Take the poll", - "POLL_QUESTION_HEADER": "Question {current}/{total}", - "POLL_ALREADY_COMPLETED": "You have already completed this poll.", - "POLL_EMPTY": "Poll is not available yet.", - "POLL_ERROR": "Unable to process the poll. Please try again later.", - "POLL_COMPLETED": "🙏 Thanks for completing the poll!", - "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 20f6ed7e..ca6da8ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,50 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", - "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы", - "ADMIN_POLLS_CREATE": "➕ Создать опрос", - "ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}", - "ADMIN_POLLS_REWARD_DISABLED": "Награда отключена", - "ADMIN_POLLS_SEND": "📤 Отправить", - "ADMIN_POLLS_STATS": "📊 Статистика", - "ADMIN_POLLS_DELETE": "🗑️ Удалить", - "ADMIN_POLLS_BACK": "⬅️ К списку", - "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям", - "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить", - "ADMIN_POLLS_LIST_TITLE": "🗳️ Опросы", - "ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.", - "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", - "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Создание опроса\n\nВведите заголовок опроса:", - "ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.", - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}", - "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.", - "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.", - "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.", - "ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", - "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.", - "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - "ADMIN_POLLS_QUESTION_LIST_HEADER": "Вопросы:", - "ADMIN_POLLS_OPTION_BULLET": " • {option}", - "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:", - "ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:", - "ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", - "ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...", - "ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", - "ADMIN_POLLS_STATS_HEADER": "📊 Статистика опроса", - "ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", - "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", - "ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?", - "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", - "POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.", - "POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.", - "POLL_START_BUTTON": "📝 Пройти опрос", - "POLL_QUESTION_HEADER": "Вопрос {current}/{total}", - "POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.", - "POLL_EMPTY": "Опрос пока недоступен.", - "POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.", - "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", - "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" } diff --git a/app/services/poll_service.py b/app/services/poll_service.py deleted file mode 100644 index 5e8b6187..00000000 --- a/app/services/poll_service.py +++ /dev/null @@ -1,179 +0,0 @@ -import asyncio -import logging -from typing import Iterable - -from aiogram import Bot -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from sqlalchemy import and_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.database.crud.user import add_user_balance -from app.database.models import ( - Poll, - PollOption, - PollQuestion, - PollResponse, - TransactionType, - User, -) -from app.localization.texts import get_texts - -logger = logging.getLogger(__name__) - - -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) - - lines: list[str] = [f"🗳️ {poll.title}"] - if poll.description: - lines.append(poll.description) - - if poll.reward_enabled and poll.reward_amount_kopeks > 0: - reward_line = texts.t( - "POLL_INVITATION_REWARD", - "🎁 За участие вы получите {amount}.", - ).format(amount=settings.format_price(poll.reward_amount_kopeks)) - lines.append(reward_line) - - lines.append( - texts.t( - "POLL_INVITATION_START", - "Нажмите кнопку ниже, чтобы пройти опрос.", - ) - ) - - return "\n\n".join(lines) - - -def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup: - texts = get_texts(language) - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"), - callback_data=f"poll_start:{response_id}", - ) - ] - ] - ) - - -async def send_poll_to_users( - bot: Bot, - db: AsyncSession, - poll: Poll, - users: Iterable[User], -) -> dict: - sent = 0 - failed = 0 - skipped = 0 - - for index, user in enumerate(users, start=1): - existing_response = await db.execute( - select(PollResponse.id).where( - and_( - PollResponse.poll_id == poll.id, - PollResponse.user_id == user.id, - ) - ) - ) - if existing_response.scalar_one_or_none(): - skipped += 1 - continue - - response = PollResponse( - poll_id=poll.id, - user_id=user.id, - ) - db.add(response) - - try: - await db.flush() - - text = _build_poll_invitation_text(poll, user) - keyboard = build_start_keyboard(response.id, user.language) - - await bot.send_message( - chat_id=user.telegram_id, - text=text, - reply_markup=keyboard, - parse_mode="HTML", - disable_web_page_preview=True, - ) - - await db.commit() - sent += 1 - - if index % 20 == 0: - await asyncio.sleep(1) - except Exception as error: # pragma: no cover - defensive logging - failed += 1 - logger.error( - "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, - user.telegram_id, - error, - ) - await db.rollback() - - return { - "sent": sent, - "failed": failed, - "skipped": skipped, - "total": sent + failed + skipped, - } - - -async def reward_user_for_poll( - db: AsyncSession, - response: PollResponse, -) -> int: - poll = response.poll - if not poll.reward_enabled or poll.reward_amount_kopeks <= 0: - return 0 - - if response.reward_given: - return response.reward_amount_kopeks - - user = response.user - description = f"Награда за участие в опросе \"{poll.title}\"" - - success = await add_user_balance( - db, - user, - poll.reward_amount_kopeks, - description, - transaction_type=TransactionType.POLL_REWARD, - ) - - if not success: - return 0 - - response.reward_given = True - response.reward_amount_kopeks = poll.reward_amount_kopeks - await db.commit() - - return poll.reward_amount_kopeks - - -async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]: - if not response.poll or not response.poll.questions: - return None, None - - answered_question_ids = {answer.question_id for answer in response.answers} - ordered_questions = sorted(response.poll.questions, key=lambda q: q.order) - - for index, question in enumerate(ordered_questions, start=1): - if question.id not in answered_question_ids: - return index, question - - return None, None - - -async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None: - for option in question.options: - if option.id == option_id: - return option - return None diff --git a/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py deleted file mode 100644 index 3b240735..00000000 --- a/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py +++ /dev/null @@ -1,155 +0,0 @@ -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "9f0f2d5a1c7b" -down_revision: Union[str, None] = "8fd1e338eb45" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "polls", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("title", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column( - "reward_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - op.create_index("ix_polls_id", "polls", ["id"]) - - op.create_table( - "poll_questions", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("text", sa.Text(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), - ) - op.create_index("ix_poll_questions_id", "poll_questions", ["id"]) - op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"]) - - op.create_table( - "poll_options", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column("text", sa.Text(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), - ) - op.create_index("ix_poll_options_id", "poll_options", ["id"]) - op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"]) - - op.create_table( - "poll_responses", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column( - "sent_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("started_at", sa.DateTime(), nullable=True), - sa.Column("completed_at", sa.DateTime(), nullable=True), - sa.Column( - "reward_given", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - op.create_index("ix_poll_responses_id", "poll_responses", ["id"]) - op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"]) - op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"]) - - op.create_table( - "poll_answers", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("response_id", sa.Integer(), nullable=False), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column("option_id", sa.Integer(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"), - sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), - ) - op.create_index("ix_poll_answers_id", "poll_answers", ["id"]) - op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"]) - op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"]) - - -def downgrade() -> None: - op.drop_index("ix_poll_answers_question_id", table_name="poll_answers") - op.drop_index("ix_poll_answers_response_id", table_name="poll_answers") - op.drop_index("ix_poll_answers_id", table_name="poll_answers") - op.drop_table("poll_answers") - - op.drop_index("ix_poll_responses_user_id", table_name="poll_responses") - op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses") - op.drop_index("ix_poll_responses_id", table_name="poll_responses") - op.drop_table("poll_responses") - - op.drop_index("ix_poll_options_question_id", table_name="poll_options") - op.drop_index("ix_poll_options_id", table_name="poll_options") - op.drop_table("poll_options") - - op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions") - op.drop_index("ix_poll_questions_id", table_name="poll_questions") - op.drop_table("poll_questions") - - op.drop_index("ix_polls_id", table_name="polls") - op.drop_table("polls") From ecdf2fcae49d721167a2f5624e51d44bfe61b0ed Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:38:01 +0300 Subject: [PATCH 04/31] feat: add admin polls with rewards and delivery --- app/bot.py | 5 +- app/database/models.py | 126 +- app/handlers/admin/polls.py | 1259 +++++++++++++++++ app/handlers/polls.py | 322 +++++ app/keyboards/admin.py | 56 +- app/localization/locales/en.json | 82 +- app/localization/locales/ru.json | 82 +- app/states.py | 6 + .../versions/a3f94c8b91dd_add_polls_tables.py | 238 ++++ 9 files changed, 2171 insertions(+), 5 deletions(-) create mode 100644 app/handlers/admin/polls.py create mode 100644 app/handlers/polls.py create mode 100644 migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index f21b7534..da9de335 100644 --- a/app/bot.py +++ b/app/bot.py @@ -17,7 +17,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets + referral, support, server_status, common, tickets, polls ) from app.handlers import simple_subscription from app.handlers.admin import ( @@ -48,6 +48,7 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, + polls as admin_polls, ) from app.handlers.stars_payments import register_stars_handlers @@ -134,6 +135,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: support.register_handlers(dp) server_status.register_handlers(dp) tickets.register_handlers(dp) + polls.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -161,6 +163,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) + admin_polls.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) diff --git a/app/database/models.py b/app/database/models.py index d296aba9..d1283fc1 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1061,7 +1061,7 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) @@ -1079,6 +1079,130 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") + +class Poll(Base): + __tablename__ = "polls" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=False) + reward_enabled = Column(Boolean, default=False, nullable=False) + reward_amount_kopeks = Column(Integer, default=0, nullable=False) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + questions = relationship( + "PollQuestion", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollQuestion.order", + ) + runs = relationship( + "PollRun", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollRun.created_at.desc()", + ) + responses = relationship( + "PollResponse", + back_populates="poll", + cascade="all, delete-orphan", + ) + creator = relationship("User", backref="created_polls") + + +class PollQuestion(Base): + __tablename__ = "poll_questions" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + order = Column(Integer, nullable=False, default=0) + text = Column(Text, nullable=False) + + poll = relationship("Poll", back_populates="questions") + options = relationship( + "PollOption", + back_populates="question", + cascade="all, delete-orphan", + order_by="PollOption.order", + ) + + +class PollOption(Base): + __tablename__ = "poll_options" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) + order = Column(Integer, nullable=False, default=0) + text = Column(String(255), nullable=False) + + question = relationship("PollQuestion", back_populates="options") + + +class PollRun(Base): + __tablename__ = "poll_runs" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + target_type = Column(String(100), nullable=False) + status = Column(String(50), default="scheduled", nullable=False) + total_count = Column(Integer, default=0) + sent_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + completed_count = Column(Integer, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + + poll = relationship("Poll", back_populates="runs") + creator = relationship("User", backref="created_poll_runs") + + +class PollResponse(Base): + __tablename__ = "poll_responses" + __table_args__ = ( + UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True) + message_id = Column(Integer, nullable=True) + chat_id = Column(BigInteger, nullable=True) + is_completed = Column(Boolean, default=False, nullable=False) + reward_given = Column(Boolean, default=False, nullable=False) + reward_amount_kopeks = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + + poll = relationship("Poll", back_populates="responses") + run = relationship("PollRun", backref="responses") + user = relationship("User", backref="poll_responses") + current_question = relationship("PollQuestion") + answers = relationship( + "PollAnswer", + back_populates="response", + cascade="all, delete-orphan", + ) + + +class PollAnswer(Base): + __tablename__ = "poll_answers" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) + option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + response = relationship("PollResponse", back_populates="answers") + question = relationship("PollQuestion") + option = relationship("PollOption") + class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py new file mode 100644 index 00000000..8b5dbbda --- /dev/null +++ b/app/handlers/admin/polls.py @@ -0,0 +1,1259 @@ +import asyncio +import html +import logging +from datetime import datetime +from decimal import Decimal, InvalidOperation +from typing import List + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, + PollRun, + User, +) +from app.handlers.admin.messages import get_target_name, get_target_users +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +def _format_question_summary(index: int, question: PollQuestion) -> str: + escaped_question = html.escape(question.text) + lines = [f"{index}. {escaped_question}"] + for opt_index, option in enumerate(sorted(question.options, key=lambda o: o.order), start=1): + lines.append(f" {opt_index}) {html.escape(option.text)}") + return "\n".join(lines) + + +async def _get_poll(db: AsyncSession, poll_id: int) -> Poll | None: + stmt = ( + select(Poll) + .options( + selectinload(Poll.questions).selectinload(PollQuestion.options), + selectinload(Poll.runs), + ) + .where(Poll.id == poll_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +def _get_state_questions(data: dict) -> List[dict]: + return list(data.get("poll_questions", [])) + + +def _ensure_questions_present(questions: List[dict]) -> None: + if not questions: + raise ValueError("poll_without_questions") + + +@admin_required +@error_handler +async def show_polls_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + stmt = ( + select(Poll) + .options(selectinload(Poll.questions)) + .order_by(Poll.created_at.desc()) + ) + result = await db.execute(stmt) + polls = result.unique().scalars().all() + + text = ( + texts.t("ADMIN_POLLS_TITLE", "📋 Опросы") + + "\n\n" + + texts.t( + "ADMIN_POLLS_DESCRIPTION", + "Создавайте опросы и отправляйте их пользователям по категориям рассылок.", + ) + ) + + keyboard: list[list[types.InlineKeyboardButton]] = [] + for poll in polls: + question_count = len(poll.questions) + reward_label = ( + texts.t("ADMIN_POLLS_REWARD_ENABLED", "🎁 награда есть") + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_DISABLED", "без награды") + ) + button_text = f"📋 {poll.title} ({question_count}) — {reward_label}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_poll_{poll.id}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), + callback_data="admin_poll_create", + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_communications", + ) + ] + ) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_title) + await state.update_data( + poll_questions=[], + reward_enabled=False, + reward_amount_kopeks=0, + ) + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_ENTER_TITLE", + "🆕 Создание опроса\n\nВведите заголовок опроса.", + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_poll_title( + message: types.Message, + db_user: User, + state: FSMContext, +): + title = (message.text or "").strip() + texts = get_texts(db_user.language) + + if not title: + await message.answer( + texts.t("ADMIN_POLLS_ENTER_TITLE_RETRY", "❗️ Укажите непустой заголовок."), + ) + return + + await state.update_data(poll_title=title) + await state.set_state(AdminStates.creating_poll_description) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_DESCRIPTION", + "✍️ Введите описание опроса. HTML-разметка поддерживается.", + ) + ) + + +@admin_required +@error_handler +async def process_poll_description( + message: types.Message, + db_user: User, + state: FSMContext, +): + description = message.html_text or message.text or "" + description = description.strip() + texts = get_texts(db_user.language) + + if not description: + await message.answer( + texts.t("ADMIN_POLLS_ENTER_DESCRIPTION_RETRY", "❗️ Описание не может быть пустым."), + ) + return + + await state.update_data(poll_description=description) + await state.set_state(AdminStates.creating_poll_question_text) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION", + "❓ Отправьте текст первого вопроса опроса.", + ) + ) + + +@admin_required +@error_handler +async def process_poll_question_text( + message: types.Message, + db_user: User, + state: FSMContext, +): + question_text = (message.html_text or message.text or "").strip() + texts = get_texts(db_user.language) + + if not question_text: + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION_RETRY", + "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", + ) + ) + return + + await state.update_data(current_question_text=question_text) + await state.set_state(AdminStates.creating_poll_question_options) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_OPTIONS", + "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", + ) + ) + + +@admin_required +@error_handler +async def process_poll_question_options( + message: types.Message, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + raw_options = (message.text or "").splitlines() + options = [opt.strip() for opt in raw_options if opt.strip()] + + if len(options) < 2: + await message.answer( + texts.t( + "ADMIN_POLLS_NEED_MORE_OPTIONS", + "❗️ Укажите минимум два варианта ответа.", + ) + ) + return + + if len(options) > 10: + await message.answer( + texts.t( + "ADMIN_POLLS_TOO_MANY_OPTIONS", + "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", + ) + ) + return + + data = await state.get_data() + question_text = data.get("current_question_text") + if not question_text: + await message.answer( + texts.t( + "ADMIN_POLLS_QUESTION_NOT_FOUND", + "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", + ) + ) + await state.set_state(AdminStates.creating_poll_question_text) + return + + questions = _get_state_questions(data) + questions.append({"text": question_text, "options": options}) + await state.update_data( + poll_questions=questions, + current_question_text=None, + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_ADD_QUESTION", "➕ Добавить ещё вопрос"), + callback_data="admin_poll_add_question", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CONFIGURE_REWARD", "🎁 Настроить награду"), + callback_data="admin_poll_reward_menu", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), + callback_data="admin_polls", + ) + ], + ] + ) + + await state.set_state(None) + await message.answer( + texts.t( + "ADMIN_POLLS_QUESTION_ADDED", + "✅ Вопрос добавлен. Выберите действие:", + ), + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def add_another_question( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_question_text) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION_NEXT", + "❓ Отправьте текст следующего вопроса.", + ) + ) + await callback.answer() + + +def _build_reward_menu(texts, data: dict) -> tuple[str, types.InlineKeyboardMarkup]: + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + questions = _get_state_questions(data) + + questions_summary = "\n".join( + f"{idx}. {html.escape(q['text'])}" for idx, q in enumerate(questions, start=1) + ) or texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —") + + reward_text = ( + texts.t("ADMIN_POLLS_REWARD_ON", "Включена") + if reward_enabled and reward_amount > 0 + else texts.t("ADMIN_POLLS_REWARD_OFF", "Отключена") + ) + reward_amount_label = texts.format_price(reward_amount) + + text = ( + texts.t("ADMIN_POLLS_REWARD_TITLE", "🎁 Награда за участие") + + "\n\n" + + texts.t("ADMIN_POLLS_REWARD_STATUS", "Статус: {status}" ).format(status=reward_text) + + "\n" + + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT", + "Сумма: {amount}", + ).format(amount=reward_amount_label) + + "\n\n" + + texts.t("ADMIN_POLLS_REWARD_QUESTIONS", "Всего вопросов: {count}").format(count=len(questions)) + + "\n" + + questions_summary + ) + + toggle_text = ( + texts.t("ADMIN_POLLS_REWARD_DISABLE", "🚫 Отключить награду") + if reward_enabled + else texts.t("ADMIN_POLLS_REWARD_ENABLE", "🔔 Включить награду") + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data="admin_poll_toggle_reward", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_REWARD_SET_AMOUNT", "💰 Изменить сумму"), + callback_data="admin_poll_reward_amount", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SAVE", "✅ Сохранить опрос"), + callback_data="admin_poll_save", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_ADD_MORE", "➕ Добавить ещё вопрос"), + callback_data="admin_poll_add_question", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), + callback_data="admin_polls", + ) + ], + ] + ) + + return text, keyboard + + +@admin_required +@error_handler +async def show_reward_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + data = await state.get_data() + texts = get_texts(db_user.language) + try: + _ensure_questions_present(_get_state_questions(data)) + except ValueError: + await callback.answer( + texts.t( + "ADMIN_POLLS_NEED_QUESTION_FIRST", + "Добавьте хотя бы один вопрос перед настройкой награды.", + ), + show_alert=True, + ) + return + + text, keyboard = _build_reward_menu(texts, data) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def toggle_reward( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + data = await state.get_data() + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + + reward_enabled = not reward_enabled + if reward_enabled and reward_amount <= 0: + reward_amount = 1000 + + await state.update_data( + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount, + ) + + texts = get_texts(db_user.language) + text, keyboard = _build_reward_menu(texts, await state.get_data()) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def request_reward_amount( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_reward_amount) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT", + "💰 Введите сумму награды в рублях (можно с копейками).", + ) + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_reward_amount( + message: types.Message, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + raw_value = (message.text or "").replace(",", ".").strip() + + try: + value_decimal = Decimal(raw_value) + except (InvalidOperation, ValueError): + await message.answer( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_INVALID", + "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", + ) + ) + return + + if value_decimal < 0: + await message.answer( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE", + "❗️ Сумма не может быть отрицательной.", + ) + ) + return + + amount_kopeks = int((value_decimal * 100).to_integral_value()) + await state.update_data( + reward_amount_kopeks=amount_kopeks, + reward_enabled=amount_kopeks > 0, + ) + await state.set_state(None) + + data = await state.get_data() + text, keyboard = _build_reward_menu(texts, data) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + +@admin_required +@error_handler +async def save_poll( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(db_user.language) + + try: + _ensure_questions_present(_get_state_questions(data)) + except ValueError: + await callback.answer( + texts.t( + "ADMIN_POLLS_NEED_QUESTION_FIRST", + "Добавьте хотя бы один вопрос перед сохранением.", + ), + show_alert=True, + ) + return + + title = data.get("poll_title") + description = data.get("poll_description") + questions = _get_state_questions(data) + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + + if not title or not description: + await callback.answer( + texts.t( + "ADMIN_POLLS_MISSING_DATA", + "Заполните заголовок и описание перед сохранением.", + ), + show_alert=True, + ) + return + + poll = Poll( + title=title, + description=description, + reward_enabled=reward_enabled and reward_amount > 0, + reward_amount_kopeks=reward_amount if reward_amount > 0 else 0, + created_by=db_user.id, + created_at=datetime.utcnow(), + ) + + try: + db.add(poll) + await db.flush() + + for q_index, question_data in enumerate(questions, start=1): + question = PollQuestion( + poll_id=poll.id, + text=question_data["text"], + order=q_index, + ) + db.add(question) + await db.flush() + + for opt_index, option_text in enumerate(question_data["options"], start=1): + option = PollOption( + question_id=question.id, + text=option_text, + order=opt_index, + ) + db.add(option) + + await db.commit() + await state.clear() + + poll = await _get_poll(db, poll.id) + question_lines = [ + _format_question_summary(idx, question) + for idx, question in enumerate(poll.questions, start=1) + ] + reward_info = ( + texts.t( + "ADMIN_POLLS_REWARD_SUMMARY", + "🎁 Награда: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") + ) + + summary_text = ( + texts.t("ADMIN_POLLS_CREATED", "✅ Опрос сохранён!") + + "\n\n" + + f"{html.escape(poll.title)}\n" + + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions)) + + "\n" + + reward_info + + "\n\n" + + "\n".join(question_lines) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_OPEN", "📋 К опросу"), + callback_data=f"admin_poll_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_polls", + ) + ], + ] + ) + + await callback.message.edit_text( + summary_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + except Exception as exc: # pragma: no cover - defensive logging + await db.rollback() + logger.exception("Failed to create poll: %s", exc) + await callback.answer( + texts.t( + "ADMIN_POLLS_SAVE_ERROR", + "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", + ), + show_alert=True, + ) + + +@admin_required +@error_handler +async def show_poll_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден или был удалён."), + show_alert=True, + ) + return + + question_lines = [ + _format_question_summary(idx, question) + for idx, question in enumerate(poll.questions, start=1) + ] + + runs_total = sum(run.sent_count for run in poll.runs) + completions = await db.scalar( + select(func.count(PollResponse.id)).where( + PollResponse.poll_id == poll.id, + PollResponse.is_completed.is_(True), + ) + ) or 0 + + reward_info = ( + texts.t( + "ADMIN_POLLS_REWARD_SUMMARY", + "🎁 Награда: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") + ) + + description_preview = html.escape(poll.description) + + text = ( + f"📋 {html.escape(poll.title)}\n\n" + + texts.t("ADMIN_POLLS_DESCRIPTION_LABEL", "Описание:") + + f"\n{description_preview}\n\n" + + texts.t( + "ADMIN_POLLS_STATS_SENT", + "Отправлено сообщений: {count}", + ).format(count=runs_total) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_COMPLETED", + "Завершили опрос: {count}", + ).format(count=completions) + + "\n" + + reward_info + + "\n\n" + + texts.t("ADMIN_POLLS_QUESTIONS_LIST", "Вопросы:") + + "\n" + + ("\n".join(question_lines) if question_lines else texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —")) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND", "🚀 Отправить"), + callback_data=f"admin_poll_send_{poll.id}", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), + callback_data=f"admin_poll_stats_{poll.id}", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"admin_poll_delete_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_polls", + ) + ], + ] + ) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_target_selection( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll or not poll.questions: + await callback.answer( + texts.t( + "ADMIN_POLLS_NO_QUESTIONS", + "Сначала добавьте вопросы к опросу, чтобы отправлять его пользователям.", + ), + show_alert=True, + ) + return + + from app.keyboards.admin import get_poll_target_keyboard + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_SELECT_TARGET", + "🎯 Выберите категорию пользователей для отправки опроса.", + ), + reply_markup=get_poll_target_keyboard(poll.id, db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def preview_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, _, poll_id_str, target = callback.data.split("_", 3) + poll_id = int(poll_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + users = await get_target_users(db, target) + target_name = get_target_name(target) + + confirm_keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CONFIRM_SEND", "✅ Отправить"), + callback_data=f"admin_poll_send_confirm_{poll_id}_{target}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_send_{poll_id}", + ) + ], + ] + ) + + text = ( + texts.t("ADMIN_POLLS_CONFIRMATION_TITLE", "📨 Подтверждение отправки") + + "\n\n" + + texts.t( + "ADMIN_POLLS_CONFIRMATION_BODY", + "Категория: {category}\nПользователей: {count}", + ).format(category=target_name, count=len(users)) + + "\n\n" + + texts.t( + "ADMIN_POLLS_CONFIRMATION_HINT", + "После отправки пользователи получат приглашение пройти опрос.", + ) + ) + + await callback.message.edit_text( + text, + reply_markup=confirm_keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +async def _send_poll_invitation( + bot: types.Bot, + poll: Poll, + run: PollRun, + users: list, +) -> tuple[int, int]: + sent_count = 0 + failed_count = 0 + + invite_text = ( + f"📋 {html.escape(poll.title)}\n\n" + f"{poll.description}\n\n" + "📝 Нажмите кнопку ниже, чтобы пройти опрос." + ) + + for index, user in enumerate(users, start=1): + try: + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="📝 Пройти опрос", + callback_data=f"poll_start_{poll.id}_{run.id}", + ) + ] + ] + ) + await bot.send_message( + chat_id=user.telegram_id, + text=invite_text, + reply_markup=keyboard, + ) + sent_count += 1 + except Exception as exc: # pragma: no cover - defensive logging + failed_count += 1 + logger.warning( + "Failed to send poll %s to user %s: %s", + poll.id, + getattr(user, "telegram_id", "unknown"), + exc, + ) + if index % 25 == 0: + await asyncio.sleep(0.5) + + return sent_count, failed_count + + +@admin_required +@error_handler +async def confirm_poll_sending( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + parts = callback.data.split("_") + if len(parts) < 6: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + try: + poll_id = int(parts[4]) + except ValueError: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + target = "_".join(parts[5:]) + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + users = await get_target_users(db, target) + if not users: + await callback.answer( + texts.t( + "ADMIN_POLLS_NO_USERS", + "Подходящих пользователей не найдено для выбранной категории.", + ), + show_alert=True, + ) + return + + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SENDING", "📨 Отправляем опрос..."), + ) + + run = PollRun( + poll_id=poll.id, + target_type=target, + status="in_progress", + total_count=len(users), + created_by=db_user.id, + created_at=datetime.utcnow(), + started_at=datetime.utcnow(), + ) + db.add(run) + await db.flush() + + sent_count, failed_count = await _send_poll_invitation(callback.bot, poll, run, users) + + run.sent_count = sent_count + run.failed_count = failed_count + run.status = "completed" + run.completed_at = datetime.utcnow() + + await db.commit() + + result_text = ( + texts.t("ADMIN_POLLS_SENT", "✅ Отправка завершена!") + + "\n\n" + + texts.t("ADMIN_POLLS_SENT_SUCCESS", "Успешно отправлено: {count}").format(count=sent_count) + + "\n" + + texts.t("ADMIN_POLLS_SENT_FAILED", "Ошибок доставки: {count}").format(count=failed_count) + + "\n" + + texts.t("ADMIN_POLLS_SENT_TOTAL", "Всего пользователей: {count}").format(count=len(users)) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), + callback_data=f"admin_poll_stats_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + result_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + total_responses = await db.scalar( + select(func.count(PollResponse.id)).where(PollResponse.poll_id == poll.id) + ) or 0 + completed_responses = await db.scalar( + select(func.count(PollResponse.id)).where( + PollResponse.poll_id == poll.id, + PollResponse.is_completed.is_(True), + ) + ) or 0 + reward_sum = await db.scalar( + select(func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0)).where( + PollResponse.poll_id == poll.id, + PollResponse.reward_given.is_(True), + ) + ) or 0 + + runs_total = await db.scalar( + select(func.coalesce(func.sum(PollRun.sent_count), 0)).where(PollRun.poll_id == poll.id) + ) or 0 + + answers_stmt = ( + select(PollAnswer.question_id, PollAnswer.option_id, func.count(PollAnswer.id)) + .join(PollResponse, PollResponse.id == PollAnswer.response_id) + .where(PollResponse.poll_id == poll.id) + .group_by(PollAnswer.question_id, PollAnswer.option_id) + ) + answers_result = await db.execute(answers_stmt) + answer_counts = { + (question_id, option_id): count + for question_id, option_id, count in answers_result.all() + } + + question_lines = [] + for question in sorted(poll.questions, key=lambda q: q.order): + total_answers_for_question = sum( + answer_counts.get((question.id, option.id), 0) + for option in question.options + ) or 0 + question_lines.append(f"{html.escape(question.text)}") + for option in sorted(question.options, key=lambda o: o.order): + option_count = answer_counts.get((question.id, option.id), 0) + percent = ( + round(option_count / total_answers_for_question * 100, 1) + if total_answers_for_question + else 0 + ) + question_lines.append( + texts.t( + "ADMIN_POLLS_STATS_OPTION", + "• {text} — {count} ({percent}%)", + ).format( + text=html.escape(option.text), + count=option_count, + percent=percent, + ) + ) + question_lines.append("") + + text = ( + texts.t("ADMIN_POLLS_STATS_TITLE", "📊 Статистика опроса") + + "\n\n" + + f"{html.escape(poll.title)}\n" + + texts.t("ADMIN_POLLS_STATS_SENT", "Сообщений отправлено: {count}").format(count=runs_total) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_RESPONDED", + "Ответов получено: {count}", + ).format(count=total_responses) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_COMPLETED_LABEL", + "Прошли до конца: {count}", + ).format(count=completed_responses) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_REWARD_TOTAL", + "Выдано наград: {amount}", + ).format(amount=texts.format_price(reward_sum)) + + "\n\n" + + ("\n".join(question_lines).strip() or texts.t("ADMIN_POLLS_STATS_NO_DATA", "Ответов пока нет.")) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def ask_delete_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE_CONFIRM", "🗑️ Удалить"), + callback_data=f"admin_poll_delete_confirm_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_DELETE_PROMPT", + "❓ Удалить опрос? Это действие нельзя отменить.", + ), + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await db.get(Poll, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос уже удалён."), + show_alert=True, + ) + return + + await db.delete(poll) + await db.commit() + + await callback.message.edit_text( + texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_BACK_TO_LIST", "⬅️ К списку опросов"), + callback_data="admin_polls", + ) + ] + ] + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(show_polls_menu, F.data == "admin_polls") + dp.callback_query.register(start_poll_creation, F.data == "admin_poll_create") + dp.message.register(process_poll_title, AdminStates.creating_poll_title) + dp.message.register(process_poll_description, AdminStates.creating_poll_description) + dp.message.register(process_poll_question_text, AdminStates.creating_poll_question_text) + dp.message.register(process_poll_question_options, AdminStates.creating_poll_question_options) + dp.message.register(process_reward_amount, AdminStates.creating_poll_reward_amount) + + dp.callback_query.register(add_another_question, F.data == "admin_poll_add_question") + dp.callback_query.register(show_reward_menu, F.data == "admin_poll_reward_menu") + dp.callback_query.register(toggle_reward, F.data == "admin_poll_toggle_reward") + dp.callback_query.register(request_reward_amount, F.data == "admin_poll_reward_amount") + dp.callback_query.register(save_poll, F.data == "admin_poll_save") + + dp.callback_query.register( + show_poll_details, + F.data.regexp(r"^admin_poll_(?!send_|stats_|delete_|create).+"), + ) + dp.callback_query.register( + show_poll_target_selection, + F.data.regexp(r"^admin_poll_send_\\d+$"), + ) + dp.callback_query.register(preview_poll_target, F.data.startswith("poll_target_")) + dp.callback_query.register( + confirm_poll_sending, + F.data.regexp(r"^admin_poll_send_confirm_\\d+_.+"), + ) + dp.callback_query.register( + show_poll_stats, + F.data.regexp(r"^admin_poll_stats_\\d+$"), + ) + dp.callback_query.register( + ask_delete_poll, + F.data.regexp(r"^admin_poll_delete_\\d+$"), + ) + dp.callback_query.register( + delete_poll, + F.data.regexp(r"^admin_poll_delete_confirm_\\d+$"), + ) diff --git a/app/handlers/polls.py b/app/handlers/polls.py new file mode 100644 index 00000000..64e08416 --- /dev/null +++ b/app/handlers/polls.py @@ -0,0 +1,322 @@ +import asyncio +import html +import logging +from datetime import datetime + +from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.user import add_user_balance +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, + PollRun, + User, +) +from app.localization.texts import get_texts +from app.utils.decorators import error_handler + +logger = logging.getLogger(__name__) + + +async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None: + stmt = ( + select(Poll) + .options(selectinload(Poll.questions).selectinload(PollQuestion.options)) + .where(Poll.id == poll_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +async def _get_response( + db: AsyncSession, + poll_id: int, + user_id: int, +) -> PollResponse | None: + stmt = ( + select(PollResponse) + .options(selectinload(PollResponse.answers)) + .where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +def _get_next_question( + poll: Poll, + response: PollResponse, +) -> PollQuestion | None: + questions = sorted(poll.questions, key=lambda q: q.order) + answered_ids = {answer.question_id for answer in response.answers} + for question in questions: + if question.id not in answered_ids: + return question + return None + + +async def _delete_message_after_delay(bot: types.Bot, chat_id: int, message_id: int) -> None: + await asyncio.sleep(10) + try: + await bot.delete_message(chat_id, message_id) + except TelegramBadRequest: + pass + except Exception as exc: # pragma: no cover - defensive logging + logger.warning("Failed to delete poll message %s: %s", message_id, exc) + + +def _build_question_text( + poll: Poll, + question: PollQuestion, + question_index: int, + total_questions: int, + include_description: bool, +) -> str: + header = f"📋 {html.escape(poll.title)}\n\n" + body = "" + if include_description: + body += f"{poll.description}\n\n" + body += ( + f"❓ {question_index}/{total_questions}\n" + f"{html.escape(question.text)}" + ) + return header + body + + +def _build_question_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: + buttons = [] + for option in sorted(question.options, key=lambda o: o.order): + buttons.append( + [ + types.InlineKeyboardButton( + text=option.text, + callback_data=f"poll_answer_{response_id}_{question.id}_{option.id}", + ) + ] + ) + return types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +@error_handler +async def start_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, poll_id_str, run_id_str = callback.data.split("_", 2) + poll_id = int(poll_id_str) + run_id = int(run_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll_with_questions(db, poll_id) + if not poll or not poll.questions: + await callback.answer( + texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), + show_alert=True, + ) + return + + response = await _get_response(db, poll.id, db_user.id) + if response and response.is_completed: + await callback.answer( + texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), + show_alert=True, + ) + return + + if not response: + response = PollResponse( + poll_id=poll.id, + run_id=run_id, + user_id=db_user.id, + message_id=callback.message.message_id, + chat_id=callback.message.chat.id, + created_at=datetime.utcnow(), + ) + db.add(response) + await db.flush() + else: + response.message_id = callback.message.message_id + response.chat_id = callback.message.chat.id + await db.flush() + + next_question = _get_next_question(poll, response) + if not next_question: + await callback.answer( + texts.t("POLL_NO_QUESTIONS", "Вопросы опроса не найдены."), + show_alert=True, + ) + return + + response.current_question_id = next_question.id + await db.commit() + + question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 + total_questions = len(poll.questions) + include_description = len(response.answers) == 0 + text = _build_question_text(poll, next_question, question_index, total_questions, include_description) + keyboard = _build_question_keyboard(response.id, next_question) + + try: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + await callback.answer() + + +@error_handler +async def answer_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, response_id_str, question_id_str, option_id_str = callback.data.split("_", 3) + response_id = int(response_id_str) + question_id = int(question_id_str) + option_id = int(option_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + response = await db.get(PollResponse, response_id) + if not response or response.user_id != db_user.id: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + if response.is_completed: + await callback.answer( + texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), + show_alert=True, + ) + return + + poll = await _get_poll_with_questions(db, response.poll_id) + if not poll: + await callback.answer(texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), show_alert=True) + return + + question = next((q for q in poll.questions if q.id == question_id), None) + if not question: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + option = next((opt for opt in question.options if opt.id == option_id), None) + if not option: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + existing_answer = next((ans for ans in response.answers if ans.question_id == question_id), None) + if existing_answer: + await callback.answer(texts.t("POLL_OPTION_ALREADY_CHOSEN", "Ответ уже выбран.")) + return + + answer = PollAnswer( + response_id=response.id, + question_id=question.id, + option_id=option.id, + ) + db.add(answer) + await db.flush() + + response.answers.append(answer) + + next_question = _get_next_question(poll, response) + + if next_question: + response.current_question_id = next_question.id + await db.commit() + + question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 + total_questions = len(poll.questions) + include_description = False + text = _build_question_text(poll, next_question, question_index, total_questions, include_description) + keyboard = _build_question_keyboard(response.id, next_question) + + try: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + await callback.answer() + return + + # Completed + response.current_question_id = None + response.is_completed = True + response.completed_at = datetime.utcnow() + + reward_text = "" + if poll.reward_enabled and poll.reward_amount_kopeks > 0 and not response.reward_given: + success = await add_user_balance( + db, + db_user, + poll.reward_amount_kopeks, + description=texts.t( + "POLL_REWARD_DESCRIPTION", + "Награда за участие в опросе '{title}'", + ).format(title=poll.title), + ) + if success: + response.reward_given = True + response.reward_amount_kopeks = poll.reward_amount_kopeks + reward_text = texts.t( + "POLL_REWARD_RECEIVED", + "\n\n🎁 На баланс зачислено: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + else: + logger.warning("Failed to add reward for poll %s to user %s", poll.id, db_user.telegram_id) + + if response.run_id: + run = await db.get(PollRun, response.run_id) + if run: + run.completed_count = (run.completed_count or 0) + 1 + + await db.commit() + + thank_you_text = ( + texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!") + + reward_text + ) + + try: + await callback.message.edit_text(thank_you_text) + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(thank_you_text) + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + + if response.chat_id and response.message_id: + asyncio.create_task( + _delete_message_after_delay(callback.bot, response.chat_id, response.message_id) + ) + + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(start_poll, F.data.startswith("poll_start_")) + dp.callback_query.register(answer_poll, F.data.startswith("poll_answer_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 75e72bc0..480dd283 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -96,11 +96,17 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"), + callback_data="admin_polls" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), @@ -972,6 +978,54 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), + callback_data=f"poll_target_{poll_id}_all" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), + callback_data=f"poll_target_{poll_id}_active" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), + callback_data=f"poll_target_{poll_id}_trial" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), + callback_data=f"poll_target_{poll_id}_no" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), + callback_data=f"poll_target_{poll_id}_expiring" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), + callback_data=f"poll_target_{poll_id}_expired" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), + callback_data=f"poll_target_{poll_id}_active_zero" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), + callback_data=f"poll_target_{poll_id}_trial_zero" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")] + ]) + + def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index eee72fe3..b9206713 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,5 +1375,85 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", + "ADMIN_COMMUNICATIONS_POLLS": "📋 Polls", + "ADMIN_POLLS_TITLE": "📋 Polls", + "ADMIN_POLLS_DESCRIPTION": "Create polls and send them to users by broadcast categories.", + "ADMIN_POLLS_REWARD_ENABLED": "reward enabled", + "ADMIN_POLLS_REWARD_DISABLED": "no reward", + "ADMIN_POLLS_CREATE": "➕ Create poll", + "ADMIN_POLLS_ENTER_TITLE": "🆕 Create a poll\n\nSend the poll title.", + "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Title cannot be empty.", + "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Send the poll description. HTML markup is supported.", + "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Description cannot be empty.", + "ADMIN_POLLS_ENTER_QUESTION": "❓ Send the text of the first question.", + "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Question text cannot be empty. Please resend it.", + "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Send answer options, one per line (min 2, max 10).", + "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Provide at least two answer options.", + "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Maximum 10 options. Please send the list again.", + "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Could not find question text. Start again by creating a question.", + "ADMIN_POLLS_ADD_QUESTION": "➕ Add another question", + "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Configure reward", + "ADMIN_POLLS_CANCEL": "❌ Cancel", + "ADMIN_POLLS_QUESTION_ADDED": "✅ Question saved. Choose the next action:", + "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Send the next question text.", + "ADMIN_POLLS_NO_QUESTIONS": "— no questions yet —", + "ADMIN_POLLS_REWARD_TITLE": "🎁 Reward settings", + "ADMIN_POLLS_REWARD_STATUS": "Status: {status}", + "ADMIN_POLLS_REWARD_AMOUNT": "Amount: {amount}", + "ADMIN_POLLS_REWARD_QUESTIONS": "Total questions: {count}", + "ADMIN_POLLS_REWARD_DISABLE": "🚫 Disable reward", + "ADMIN_POLLS_REWARD_ENABLE": "🔔 Enable reward", + "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Change amount", + "ADMIN_POLLS_SAVE": "✅ Save poll", + "ADMIN_POLLS_ADD_MORE": "➕ Add another question", + "ADMIN_POLLS_NEED_QUESTION_FIRST": "Add at least one question before configuring the reward.", + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Enter reward amount in RUB (decimals allowed).", + "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Could not parse the number. Use format 10 or 12.5", + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Amount cannot be negative.", + "ADMIN_POLLS_MISSING_DATA": "Fill in title and description before saving.", + "ADMIN_POLLS_REWARD_ON": "Enabled", + "ADMIN_POLLS_REWARD_OFF": "Disabled", + "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Reward: {amount}", + "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Reward: not provided", + "ADMIN_POLLS_CREATED": "✅ Poll saved!", + "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", + "ADMIN_POLLS_OPEN": "📋 Open poll", + "ADMIN_POLLS_SAVE_ERROR": "❌ Failed to save poll. Please try again later.", + "ADMIN_POLLS_NOT_FOUND": "Poll not found or already removed.", + "ADMIN_POLLS_DESCRIPTION_LABEL": "Description:", + "ADMIN_POLLS_STATS_SENT": "Messages sent: {count}", + "ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: {count}", + "ADMIN_POLLS_QUESTIONS_LIST": "Questions:", + "ADMIN_POLLS_SEND": "🚀 Send", + "ADMIN_POLLS_STATS_BUTTON": "📊 Stats", + "ADMIN_POLLS_DELETE": "🗑️ Delete", + "ADMIN_POLLS_SELECT_TARGET": "🎯 Choose a user segment for this poll.", + "ADMIN_POLLS_CONFIRM_SEND": "✅ Send", + "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Send confirmation", + "ADMIN_POLLS_CONFIRMATION_BODY": "Segment: {category}\nUsers: {count}", + "ADMIN_POLLS_CONFIRMATION_HINT": "Users will receive an invite to complete the poll.", + "ADMIN_POLLS_NO_USERS": "No users matched the selected category.", + "ADMIN_POLLS_SENDING": "📨 Sending the poll...", + "ADMIN_POLLS_SENT": "✅ Sending completed!", + "ADMIN_POLLS_SENT_SUCCESS": "Successfully sent: {count}", + "ADMIN_POLLS_SENT_FAILED": "Failed deliveries: {count}", + "ADMIN_POLLS_SENT_TOTAL": "Total recipients: {count}", + "ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics", + "ADMIN_POLLS_STATS_RESPONDED": "Responses received: {count}", + "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: {count}", + "ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: {amount}", + "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", + "ADMIN_POLLS_STATS_NO_DATA": "No answers yet.", + "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete", + "ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.", + "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", + "ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list", + "POLL_NOT_AVAILABLE": "Poll is not available.", + "POLL_ALREADY_PASSED": "You have already completed this poll.", + "POLL_NO_QUESTIONS": "No poll questions found.", + "POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.", + "POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'", + "POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}", + "POLL_COMPLETED": "🙏 Thanks for completing the poll!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ca6da8ca..53ef53cd 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,5 +1375,85 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", + "ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы", + "ADMIN_POLLS_TITLE": "📋 Опросы", + "ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.", + "ADMIN_POLLS_REWARD_ENABLED": "есть награда", + "ADMIN_POLLS_REWARD_DISABLED": "без награды", + "ADMIN_POLLS_CREATE": "➕ Создать опрос", + "ADMIN_POLLS_ENTER_TITLE": "🆕 Создание опроса\n\nВведите заголовок опроса.", + "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Укажите непустой заголовок.", + "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Введите описание опроса. HTML-разметка поддерживается.", + "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Описание не может быть пустым.", + "ADMIN_POLLS_ENTER_QUESTION": "❓ Отправьте текст первого вопроса опроса.", + "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", + "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", + "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Укажите минимум два варианта ответа.", + "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", + "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", + "ADMIN_POLLS_ADD_QUESTION": "➕ Добавить ещё вопрос", + "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Настроить награду", + "ADMIN_POLLS_CANCEL": "❌ Отмена", + "ADMIN_POLLS_QUESTION_ADDED": "✅ Вопрос добавлен. Выберите действие:", + "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Отправьте текст следующего вопроса.", + "ADMIN_POLLS_NO_QUESTIONS": "— вопросы не добавлены —", + "ADMIN_POLLS_REWARD_TITLE": "🎁 Награда за участие", + "ADMIN_POLLS_REWARD_STATUS": "Статус: {status}", + "ADMIN_POLLS_REWARD_AMOUNT": "Сумма: {amount}", + "ADMIN_POLLS_REWARD_QUESTIONS": "Всего вопросов: {count}", + "ADMIN_POLLS_REWARD_DISABLE": "🚫 Отключить награду", + "ADMIN_POLLS_REWARD_ENABLE": "🔔 Включить награду", + "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Изменить сумму", + "ADMIN_POLLS_SAVE": "✅ Сохранить опрос", + "ADMIN_POLLS_ADD_MORE": "➕ Добавить ещё вопрос", + "ADMIN_POLLS_NEED_QUESTION_FIRST": "Добавьте хотя бы один вопрос перед настройкой награды.", + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Введите сумму награды в рублях (можно с копейками).", + "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Сумма не может быть отрицательной.", + "ADMIN_POLLS_MISSING_DATA": "Заполните заголовок и описание перед сохранением.", + "ADMIN_POLLS_REWARD_ON": "Включена", + "ADMIN_POLLS_REWARD_OFF": "Отключена", + "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Награда: {amount}", + "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Награда: не выдается", + "ADMIN_POLLS_CREATED": "✅ Опрос сохранён!", + "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", + "ADMIN_POLLS_OPEN": "📋 К опросу", + "ADMIN_POLLS_SAVE_ERROR": "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", + "ADMIN_POLLS_NOT_FOUND": "Опрос не найден или был удалён.", + "ADMIN_POLLS_DESCRIPTION_LABEL": "Описание:", + "ADMIN_POLLS_STATS_SENT": "Отправлено сообщений: {count}", + "ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: {count}", + "ADMIN_POLLS_QUESTIONS_LIST": "Вопросы:", + "ADMIN_POLLS_SEND": "🚀 Отправить", + "ADMIN_POLLS_STATS_BUTTON": "📊 Статистика", + "ADMIN_POLLS_DELETE": "🗑️ Удалить", + "ADMIN_POLLS_SELECT_TARGET": "🎯 Выберите категорию пользователей для отправки опроса.", + "ADMIN_POLLS_CONFIRM_SEND": "✅ Отправить", + "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Подтверждение отправки", + "ADMIN_POLLS_CONFIRMATION_BODY": "Категория: {category}\nПользователей: {count}", + "ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.", + "ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.", + "ADMIN_POLLS_SENDING": "📨 Отправляем опрос...", + "ADMIN_POLLS_SENT": "✅ Отправка завершена!", + "ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: {count}", + "ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: {count}", + "ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: {count}", + "ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса", + "ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: {count}", + "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: {count}", + "ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: {amount}", + "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", + "ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.", + "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить", + "ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.", + "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", + "ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов", + "POLL_NOT_AVAILABLE": "Опрос недоступен.", + "POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.", + "POLL_NO_QUESTIONS": "Вопросы опроса не найдены.", + "POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.", + "POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'", + "POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}", + "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!" } diff --git a/app/states.py b/app/states.py index 549e314f..b88ce142 100644 --- a/app/states.py +++ b/app/states.py @@ -70,6 +70,12 @@ class AdminStates(StatesGroup): waiting_for_broadcast_media = State() confirming_broadcast = State() + creating_poll_title = State() + creating_poll_description = State() + creating_poll_question_text = State() + creating_poll_question_options = State() + creating_poll_reward_amount = State() + creating_promo_group_name = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() diff --git a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py new file mode 100644 index 00000000..31da4428 --- /dev/null +++ b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py @@ -0,0 +1,238 @@ +"""add polls tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + +revision: str = "a3f94c8b91dd" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +POLL_TABLE = "polls" +POLL_QUESTIONS_TABLE = "poll_questions" +POLL_OPTIONS_TABLE = "poll_options" +POLL_RUNS_TABLE = "poll_runs" +POLL_RESPONSES_TABLE = "poll_responses" +POLL_ANSWERS_TABLE = "poll_answers" + + +def _table_exists(inspector: Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_TABLE): + op.create_table( + POLL_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column( + "reward_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_QUESTIONS_TABLE): + op.create_table( + POLL_QUESTIONS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("text", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_OPTIONS_TABLE): + op.create_table( + POLL_OPTIONS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("text", sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint( + ["question_id"], + [f"{POLL_QUESTIONS_TABLE}.id"], + ondelete="CASCADE", + ), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_RUNS_TABLE): + op.create_table( + POLL_RUNS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("target_type", sa.String(length=100), nullable=False), + sa.Column( + "status", + sa.String(length=50), + nullable=False, + server_default="scheduled", + ), + sa.Column( + "total_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "sent_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "failed_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "completed_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_RESPONSES_TABLE): + op.create_table( + POLL_RESPONSES_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("run_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("current_question_id", sa.Integer(), nullable=True), + sa.Column("message_id", sa.Integer(), nullable=True), + sa.Column("chat_id", sa.BigInteger(), nullable=True), + sa.Column( + "is_completed", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_given", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"), + sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_ANSWERS_TABLE): + op.create_table( + POLL_ANSWERS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("response_id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("option_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_ANSWERS_TABLE): + op.drop_table(POLL_ANSWERS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_RESPONSES_TABLE): + op.drop_table(POLL_RESPONSES_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_RUNS_TABLE): + op.drop_table(POLL_RUNS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_OPTIONS_TABLE): + op.drop_table(POLL_OPTIONS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_QUESTIONS_TABLE): + op.drop_table(POLL_QUESTIONS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_TABLE): + op.drop_table(POLL_TABLE) From 3a5d838be3bb76a4fc848922adfbd136160e3c5b Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:55:06 +0300 Subject: [PATCH 05/31] Revert "Add admin-managed polls with user rewards" --- app/bot.py | 5 +- app/database/models.py | 126 +- app/handlers/admin/polls.py | 1259 ----------------- app/handlers/polls.py | 322 ----- app/keyboards/admin.py | 56 +- app/localization/locales/en.json | 82 +- app/localization/locales/ru.json | 82 +- app/states.py | 6 - .../versions/a3f94c8b91dd_add_polls_tables.py | 238 ---- 9 files changed, 5 insertions(+), 2171 deletions(-) delete mode 100644 app/handlers/admin/polls.py delete mode 100644 app/handlers/polls.py delete mode 100644 migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index da9de335..f21b7534 100644 --- a/app/bot.py +++ b/app/bot.py @@ -17,7 +17,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets, polls + referral, support, server_status, common, tickets ) from app.handlers import simple_subscription from app.handlers.admin import ( @@ -48,7 +48,6 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, - polls as admin_polls, ) from app.handlers.stars_payments import register_stars_handlers @@ -135,7 +134,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: support.register_handlers(dp) server_status.register_handlers(dp) tickets.register_handlers(dp) - polls.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -163,7 +161,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) - admin_polls.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) diff --git a/app/database/models.py b/app/database/models.py index d1283fc1..d296aba9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1061,7 +1061,7 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) @@ -1079,130 +1079,6 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") - -class Poll(Base): - __tablename__ = "polls" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=False) - reward_enabled = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - questions = relationship( - "PollQuestion", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollQuestion.order", - ) - runs = relationship( - "PollRun", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollRun.created_at.desc()", - ) - responses = relationship( - "PollResponse", - back_populates="poll", - cascade="all, delete-orphan", - ) - creator = relationship("User", backref="created_polls") - - -class PollQuestion(Base): - __tablename__ = "poll_questions" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(Text, nullable=False) - - poll = relationship("Poll", back_populates="questions") - options = relationship( - "PollOption", - back_populates="question", - cascade="all, delete-orphan", - order_by="PollOption.order", - ) - - -class PollOption(Base): - __tablename__ = "poll_options" - - id = Column(Integer, primary_key=True, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(String(255), nullable=False) - - question = relationship("PollQuestion", back_populates="options") - - -class PollRun(Base): - __tablename__ = "poll_runs" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - target_type = Column(String(100), nullable=False) - status = Column(String(50), default="scheduled", nullable=False) - total_count = Column(Integer, default=0) - sent_count = Column(Integer, default=0) - failed_count = Column(Integer, default=0) - completed_count = Column(Integer, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="runs") - creator = relationship("User", backref="created_poll_runs") - - -class PollResponse(Base): - __tablename__ = "poll_responses" - __table_args__ = ( - UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True) - message_id = Column(Integer, nullable=True) - chat_id = Column(BigInteger, nullable=True) - is_completed = Column(Boolean, default=False, nullable=False) - reward_given = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="responses") - run = relationship("PollRun", backref="responses") - user = relationship("User", backref="poll_responses") - current_question = relationship("PollQuestion") - answers = relationship( - "PollAnswer", - back_populates="response", - cascade="all, delete-orphan", - ) - - -class PollAnswer(Base): - __tablename__ = "poll_answers" - - id = Column(Integer, primary_key=True, index=True) - response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - response = relationship("PollResponse", back_populates="answers") - question = relationship("PollQuestion") - option = relationship("PollOption") - class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py deleted file mode 100644 index 8b5dbbda..00000000 --- a/app/handlers/admin/polls.py +++ /dev/null @@ -1,1259 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime -from decimal import Decimal, InvalidOperation -from typing import List - -from aiogram import Dispatcher, F, types -from aiogram.fsm.context import FSMContext -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.handlers.admin.messages import get_target_name, get_target_users -from app.localization.texts import get_texts -from app.states import AdminStates -from app.utils.decorators import admin_required, error_handler - -logger = logging.getLogger(__name__) - - -def _format_question_summary(index: int, question: PollQuestion) -> str: - escaped_question = html.escape(question.text) - lines = [f"{index}. {escaped_question}"] - for opt_index, option in enumerate(sorted(question.options, key=lambda o: o.order), start=1): - lines.append(f" {opt_index}) {html.escape(option.text)}") - return "\n".join(lines) - - -async def _get_poll(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options( - selectinload(Poll.questions).selectinload(PollQuestion.options), - selectinload(Poll.runs), - ) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_state_questions(data: dict) -> List[dict]: - return list(data.get("poll_questions", [])) - - -def _ensure_questions_present(questions: List[dict]) -> None: - if not questions: - raise ValueError("poll_without_questions") - - -@admin_required -@error_handler -async def show_polls_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - stmt = ( - select(Poll) - .options(selectinload(Poll.questions)) - .order_by(Poll.created_at.desc()) - ) - result = await db.execute(stmt) - polls = result.unique().scalars().all() - - text = ( - texts.t("ADMIN_POLLS_TITLE", "📋 Опросы") - + "\n\n" - + texts.t( - "ADMIN_POLLS_DESCRIPTION", - "Создавайте опросы и отправляйте их пользователям по категориям рассылок.", - ) - ) - - keyboard: list[list[types.InlineKeyboardButton]] = [] - for poll in polls: - question_count = len(poll.questions) - reward_label = ( - texts.t("ADMIN_POLLS_REWARD_ENABLED", "🎁 награда есть") - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_DISABLED", "без награды") - ) - button_text = f"📋 {poll.title} ({question_count}) — {reward_label}" - keyboard.append( - [ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_poll_{poll.id}", - ) - ] - ) - - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), - callback_data="admin_poll_create", - ) - ] - ) - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_communications", - ) - ] - ) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_poll_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_title) - await state.update_data( - poll_questions=[], - reward_enabled=False, - reward_amount_kopeks=0, - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_TITLE", - "🆕 Создание опроса\n\nВведите заголовок опроса.", - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_poll_title( - message: types.Message, - db_user: User, - state: FSMContext, -): - title = (message.text or "").strip() - texts = get_texts(db_user.language) - - if not title: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_TITLE_RETRY", "❗️ Укажите непустой заголовок."), - ) - return - - await state.update_data(poll_title=title) - await state.set_state(AdminStates.creating_poll_description) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_DESCRIPTION", - "✍️ Введите описание опроса. HTML-разметка поддерживается.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_description( - message: types.Message, - db_user: User, - state: FSMContext, -): - description = message.html_text or message.text or "" - description = description.strip() - texts = get_texts(db_user.language) - - if not description: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_DESCRIPTION_RETRY", "❗️ Описание не может быть пустым."), - ) - return - - await state.update_data(poll_description=description) - await state.set_state(AdminStates.creating_poll_question_text) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION", - "❓ Отправьте текст первого вопроса опроса.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_text( - message: types.Message, - db_user: User, - state: FSMContext, -): - question_text = (message.html_text or message.text or "").strip() - texts = get_texts(db_user.language) - - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_RETRY", - "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - ) - ) - return - - await state.update_data(current_question_text=question_text) - await state.set_state(AdminStates.creating_poll_question_options) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_OPTIONS", - "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_options( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_options = (message.text or "").splitlines() - options = [opt.strip() for opt in raw_options if opt.strip()] - - if len(options) < 2: - await message.answer( - texts.t( - "ADMIN_POLLS_NEED_MORE_OPTIONS", - "❗️ Укажите минимум два варианта ответа.", - ) - ) - return - - if len(options) > 10: - await message.answer( - texts.t( - "ADMIN_POLLS_TOO_MANY_OPTIONS", - "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - ) - ) - return - - data = await state.get_data() - question_text = data.get("current_question_text") - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_NOT_FOUND", - "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - ) - ) - await state.set_state(AdminStates.creating_poll_question_text) - return - - questions = _get_state_questions(data) - questions.append({"text": question_text, "options": options}) - await state.update_data( - poll_questions=questions, - current_question_text=None, - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_QUESTION", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIGURE_REWARD", "🎁 Настроить награду"), - callback_data="admin_poll_reward_menu", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - await state.set_state(None) - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_ADDED", - "✅ Вопрос добавлен. Выберите действие:", - ), - reply_markup=keyboard, - ) - - -@admin_required -@error_handler -async def add_another_question( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_question_text) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_NEXT", - "❓ Отправьте текст следующего вопроса.", - ) - ) - await callback.answer() - - -def _build_reward_menu(texts, data: dict) -> tuple[str, types.InlineKeyboardMarkup]: - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - questions = _get_state_questions(data) - - questions_summary = "\n".join( - f"{idx}. {html.escape(q['text'])}" for idx, q in enumerate(questions, start=1) - ) or texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —") - - reward_text = ( - texts.t("ADMIN_POLLS_REWARD_ON", "Включена") - if reward_enabled and reward_amount > 0 - else texts.t("ADMIN_POLLS_REWARD_OFF", "Отключена") - ) - reward_amount_label = texts.format_price(reward_amount) - - text = ( - texts.t("ADMIN_POLLS_REWARD_TITLE", "🎁 Награда за участие") - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_STATUS", "Статус: {status}" ).format(status=reward_text) - + "\n" - + texts.t( - "ADMIN_POLLS_REWARD_AMOUNT", - "Сумма: {amount}", - ).format(amount=reward_amount_label) - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_QUESTIONS", "Всего вопросов: {count}").format(count=len(questions)) - + "\n" - + questions_summary - ) - - toggle_text = ( - texts.t("ADMIN_POLLS_REWARD_DISABLE", "🚫 Отключить награду") - if reward_enabled - else texts.t("ADMIN_POLLS_REWARD_ENABLE", "🔔 Включить награду") - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_poll_toggle_reward", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_REWARD_SET_AMOUNT", "💰 Изменить сумму"), - callback_data="admin_poll_reward_amount", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SAVE", "✅ Сохранить опрос"), - callback_data="admin_poll_save", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_MORE", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - return text, keyboard - - -@admin_required -@error_handler -async def show_reward_menu( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - texts = get_texts(db_user.language) - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед настройкой награды.", - ), - show_alert=True, - ) - return - - text, keyboard = _build_reward_menu(texts, data) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def toggle_reward( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - reward_enabled = not reward_enabled - if reward_enabled and reward_amount <= 0: - reward_amount = 1000 - - await state.update_data( - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_amount, - ) - - texts = get_texts(db_user.language) - text, keyboard = _build_reward_menu(texts, await state.get_data()) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def request_reward_amount( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_reward_amount) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT", - "💰 Введите сумму награды в рублях (можно с копейками).", - ) - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_reward_amount( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_value = (message.text or "").replace(",", ".").strip() - - try: - value_decimal = Decimal(raw_value) - except (InvalidOperation, ValueError): - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_INVALID", - "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - ) - ) - return - - if value_decimal < 0: - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE", - "❗️ Сумма не может быть отрицательной.", - ) - ) - return - - amount_kopeks = int((value_decimal * 100).to_integral_value()) - await state.update_data( - reward_amount_kopeks=amount_kopeks, - reward_enabled=amount_kopeks > 0, - ) - await state.set_state(None) - - data = await state.get_data() - text, keyboard = _build_reward_menu(texts, data) - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") - - -@admin_required -@error_handler -async def save_poll( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(db_user.language) - - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед сохранением.", - ), - show_alert=True, - ) - return - - title = data.get("poll_title") - description = data.get("poll_description") - questions = _get_state_questions(data) - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - if not title or not description: - await callback.answer( - texts.t( - "ADMIN_POLLS_MISSING_DATA", - "Заполните заголовок и описание перед сохранением.", - ), - show_alert=True, - ) - return - - poll = Poll( - title=title, - description=description, - reward_enabled=reward_enabled and reward_amount > 0, - reward_amount_kopeks=reward_amount if reward_amount > 0 else 0, - created_by=db_user.id, - created_at=datetime.utcnow(), - ) - - try: - db.add(poll) - await db.flush() - - for q_index, question_data in enumerate(questions, start=1): - question = PollQuestion( - poll_id=poll.id, - text=question_data["text"], - order=q_index, - ) - db.add(question) - await db.flush() - - for opt_index, option_text in enumerate(question_data["options"], start=1): - option = PollOption( - question_id=question.id, - text=option_text, - order=opt_index, - ) - db.add(option) - - await db.commit() - await state.clear() - - poll = await _get_poll(db, poll.id) - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - summary_text = ( - texts.t("ADMIN_POLLS_CREATED", "✅ Опрос сохранён!") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions)) - + "\n" - + reward_info - + "\n\n" - + "\n".join(question_lines) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_OPEN", "📋 К опросу"), - callback_data=f"admin_poll_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - summary_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - except Exception as exc: # pragma: no cover - defensive logging - await db.rollback() - logger.exception("Failed to create poll: %s", exc) - await callback.answer( - texts.t( - "ADMIN_POLLS_SAVE_ERROR", - "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - ), - show_alert=True, - ) - - -@admin_required -@error_handler -async def show_poll_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден или был удалён."), - show_alert=True, - ) - return - - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - - runs_total = sum(run.sent_count for run in poll.runs) - completions = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - description_preview = html.escape(poll.description) - - text = ( - f"📋 {html.escape(poll.title)}\n\n" - + texts.t("ADMIN_POLLS_DESCRIPTION_LABEL", "Описание:") - + f"\n{description_preview}\n\n" - + texts.t( - "ADMIN_POLLS_STATS_SENT", - "Отправлено сообщений: {count}", - ).format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED", - "Завершили опрос: {count}", - ).format(count=completions) - + "\n" - + reward_info - + "\n\n" - + texts.t("ADMIN_POLLS_QUESTIONS_LIST", "Вопросы:") - + "\n" - + ("\n".join(question_lines) if question_lines else texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND", "🚀 Отправить"), - callback_data=f"admin_poll_send_{poll.id}", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_target_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_QUESTIONS", - "Сначала добавьте вопросы к опросу, чтобы отправлять его пользователям.", - ), - show_alert=True, - ) - return - - from app.keyboards.admin import get_poll_target_keyboard - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_SELECT_TARGET", - "🎯 Выберите категорию пользователей для отправки опроса.", - ), - reply_markup=get_poll_target_keyboard(poll.id, db_user.language), - ) - await callback.answer() - - -@admin_required -@error_handler -async def preview_poll_target( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, _, poll_id_str, target = callback.data.split("_", 3) - poll_id = int(poll_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - target_name = get_target_name(target) - - confirm_keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIRM_SEND", "✅ Отправить"), - callback_data=f"admin_poll_send_confirm_{poll_id}_{target}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_send_{poll_id}", - ) - ], - ] - ) - - text = ( - texts.t("ADMIN_POLLS_CONFIRMATION_TITLE", "📨 Подтверждение отправки") - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_BODY", - "Категория: {category}\nПользователей: {count}", - ).format(category=target_name, count=len(users)) - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_HINT", - "После отправки пользователи получат приглашение пройти опрос.", - ) - ) - - await callback.message.edit_text( - text, - reply_markup=confirm_keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -async def _send_poll_invitation( - bot: types.Bot, - poll: Poll, - run: PollRun, - users: list, -) -> tuple[int, int]: - sent_count = 0 - failed_count = 0 - - invite_text = ( - f"📋 {html.escape(poll.title)}\n\n" - f"{poll.description}\n\n" - "📝 Нажмите кнопку ниже, чтобы пройти опрос." - ) - - for index, user in enumerate(users, start=1): - try: - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="📝 Пройти опрос", - callback_data=f"poll_start_{poll.id}_{run.id}", - ) - ] - ] - ) - await bot.send_message( - chat_id=user.telegram_id, - text=invite_text, - reply_markup=keyboard, - ) - sent_count += 1 - except Exception as exc: # pragma: no cover - defensive logging - failed_count += 1 - logger.warning( - "Failed to send poll %s to user %s: %s", - poll.id, - getattr(user, "telegram_id", "unknown"), - exc, - ) - if index % 25 == 0: - await asyncio.sleep(0.5) - - return sent_count, failed_count - - -@admin_required -@error_handler -async def confirm_poll_sending( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - parts = callback.data.split("_") - if len(parts) < 6: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - try: - poll_id = int(parts[4]) - except ValueError: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - target = "_".join(parts[5:]) - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - if not users: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_USERS", - "Подходящих пользователей не найдено для выбранной категории.", - ), - show_alert=True, - ) - return - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_SENDING", "📨 Отправляем опрос..."), - ) - - run = PollRun( - poll_id=poll.id, - target_type=target, - status="in_progress", - total_count=len(users), - created_by=db_user.id, - created_at=datetime.utcnow(), - started_at=datetime.utcnow(), - ) - db.add(run) - await db.flush() - - sent_count, failed_count = await _send_poll_invitation(callback.bot, poll, run, users) - - run.sent_count = sent_count - run.failed_count = failed_count - run.status = "completed" - run.completed_at = datetime.utcnow() - - await db.commit() - - result_text = ( - texts.t("ADMIN_POLLS_SENT", "✅ Отправка завершена!") - + "\n\n" - + texts.t("ADMIN_POLLS_SENT_SUCCESS", "Успешно отправлено: {count}").format(count=sent_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_FAILED", "Ошибок доставки: {count}").format(count=failed_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_TOTAL", "Всего пользователей: {count}").format(count=len(users)) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - total_responses = await db.scalar( - select(func.count(PollResponse.id)).where(PollResponse.poll_id == poll.id) - ) or 0 - completed_responses = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - reward_sum = await db.scalar( - select(func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0)).where( - PollResponse.poll_id == poll.id, - PollResponse.reward_given.is_(True), - ) - ) or 0 - - runs_total = await db.scalar( - select(func.coalesce(func.sum(PollRun.sent_count), 0)).where(PollRun.poll_id == poll.id) - ) or 0 - - answers_stmt = ( - select(PollAnswer.question_id, PollAnswer.option_id, func.count(PollAnswer.id)) - .join(PollResponse, PollResponse.id == PollAnswer.response_id) - .where(PollResponse.poll_id == poll.id) - .group_by(PollAnswer.question_id, PollAnswer.option_id) - ) - answers_result = await db.execute(answers_stmt) - answer_counts = { - (question_id, option_id): count - for question_id, option_id, count in answers_result.all() - } - - question_lines = [] - for question in sorted(poll.questions, key=lambda q: q.order): - total_answers_for_question = sum( - answer_counts.get((question.id, option.id), 0) - for option in question.options - ) or 0 - question_lines.append(f"{html.escape(question.text)}") - for option in sorted(question.options, key=lambda o: o.order): - option_count = answer_counts.get((question.id, option.id), 0) - percent = ( - round(option_count / total_answers_for_question * 100, 1) - if total_answers_for_question - else 0 - ) - question_lines.append( - texts.t( - "ADMIN_POLLS_STATS_OPTION", - "• {text} — {count} ({percent}%)", - ).format( - text=html.escape(option.text), - count=option_count, - percent=percent, - ) - ) - question_lines.append("") - - text = ( - texts.t("ADMIN_POLLS_STATS_TITLE", "📊 Статистика опроса") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_STATS_SENT", "Сообщений отправлено: {count}").format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_RESPONDED", - "Ответов получено: {count}", - ).format(count=total_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED_LABEL", - "Прошли до конца: {count}", - ).format(count=completed_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_REWARD_TOTAL", - "Выдано наград: {amount}", - ).format(amount=texts.format_price(reward_sum)) - + "\n\n" - + ("\n".join(question_lines).strip() or texts.t("ADMIN_POLLS_STATS_NO_DATA", "Ответов пока нет.")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def ask_delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE_CONFIRM", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_confirm_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_DELETE_PROMPT", - "❓ Удалить опрос? Это действие нельзя отменить.", - ), - reply_markup=keyboard, - ) - await callback.answer() - - -@admin_required -@error_handler -async def delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await db.get(Poll, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос уже удалён."), - show_alert=True, - ) - return - - await db.delete(poll) - await db.commit() - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_BACK_TO_LIST", "⬅️ К списку опросов"), - callback_data="admin_polls", - ) - ] - ] - ), - ) - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(show_polls_menu, F.data == "admin_polls") - dp.callback_query.register(start_poll_creation, F.data == "admin_poll_create") - dp.message.register(process_poll_title, AdminStates.creating_poll_title) - dp.message.register(process_poll_description, AdminStates.creating_poll_description) - dp.message.register(process_poll_question_text, AdminStates.creating_poll_question_text) - dp.message.register(process_poll_question_options, AdminStates.creating_poll_question_options) - dp.message.register(process_reward_amount, AdminStates.creating_poll_reward_amount) - - dp.callback_query.register(add_another_question, F.data == "admin_poll_add_question") - dp.callback_query.register(show_reward_menu, F.data == "admin_poll_reward_menu") - dp.callback_query.register(toggle_reward, F.data == "admin_poll_toggle_reward") - dp.callback_query.register(request_reward_amount, F.data == "admin_poll_reward_amount") - dp.callback_query.register(save_poll, F.data == "admin_poll_save") - - dp.callback_query.register( - show_poll_details, - F.data.regexp(r"^admin_poll_(?!send_|stats_|delete_|create).+"), - ) - dp.callback_query.register( - show_poll_target_selection, - F.data.regexp(r"^admin_poll_send_\\d+$"), - ) - dp.callback_query.register(preview_poll_target, F.data.startswith("poll_target_")) - dp.callback_query.register( - confirm_poll_sending, - F.data.regexp(r"^admin_poll_send_confirm_\\d+_.+"), - ) - dp.callback_query.register( - show_poll_stats, - F.data.regexp(r"^admin_poll_stats_\\d+$"), - ) - dp.callback_query.register( - ask_delete_poll, - F.data.regexp(r"^admin_poll_delete_\\d+$"), - ) - dp.callback_query.register( - delete_poll, - F.data.regexp(r"^admin_poll_delete_confirm_\\d+$"), - ) diff --git a/app/handlers/polls.py b/app/handlers/polls.py deleted file mode 100644 index 64e08416..00000000 --- a/app/handlers/polls.py +++ /dev/null @@ -1,322 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime - -from aiogram import Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.crud.user import add_user_balance -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.localization.texts import get_texts -from app.utils.decorators import error_handler - -logger = logging.getLogger(__name__) - - -async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options(selectinload(Poll.questions).selectinload(PollQuestion.options)) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -async def _get_response( - db: AsyncSession, - poll_id: int, - user_id: int, -) -> PollResponse | None: - stmt = ( - select(PollResponse) - .options(selectinload(PollResponse.answers)) - .where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_next_question( - poll: Poll, - response: PollResponse, -) -> PollQuestion | None: - questions = sorted(poll.questions, key=lambda q: q.order) - answered_ids = {answer.question_id for answer in response.answers} - for question in questions: - if question.id not in answered_ids: - return question - return None - - -async def _delete_message_after_delay(bot: types.Bot, chat_id: int, message_id: int) -> None: - await asyncio.sleep(10) - try: - await bot.delete_message(chat_id, message_id) - except TelegramBadRequest: - pass - except Exception as exc: # pragma: no cover - defensive logging - logger.warning("Failed to delete poll message %s: %s", message_id, exc) - - -def _build_question_text( - poll: Poll, - question: PollQuestion, - question_index: int, - total_questions: int, - include_description: bool, -) -> str: - header = f"📋 {html.escape(poll.title)}\n\n" - body = "" - if include_description: - body += f"{poll.description}\n\n" - body += ( - f"❓ {question_index}/{total_questions}\n" - f"{html.escape(question.text)}" - ) - return header + body - - -def _build_question_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: - buttons = [] - for option in sorted(question.options, key=lambda o: o.order): - buttons.append( - [ - types.InlineKeyboardButton( - text=option.text, - callback_data=f"poll_answer_{response_id}_{question.id}_{option.id}", - ) - ] - ) - return types.InlineKeyboardMarkup(inline_keyboard=buttons) - - -@error_handler -async def start_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, poll_id_str, run_id_str = callback.data.split("_", 2) - poll_id = int(poll_id_str) - run_id = int(run_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll_with_questions(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), - show_alert=True, - ) - return - - response = await _get_response(db, poll.id, db_user.id) - if response and response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - if not response: - response = PollResponse( - poll_id=poll.id, - run_id=run_id, - user_id=db_user.id, - message_id=callback.message.message_id, - chat_id=callback.message.chat.id, - created_at=datetime.utcnow(), - ) - db.add(response) - await db.flush() - else: - response.message_id = callback.message.message_id - response.chat_id = callback.message.chat.id - await db.flush() - - next_question = _get_next_question(poll, response) - if not next_question: - await callback.answer( - texts.t("POLL_NO_QUESTIONS", "Вопросы опроса не найдены."), - show_alert=True, - ) - return - - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = len(response.answers) == 0 - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - - -@error_handler -async def answer_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, response_id_str, question_id_str, option_id_str = callback.data.split("_", 3) - response_id = int(response_id_str) - question_id = int(question_id_str) - option_id = int(option_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - response = await db.get(PollResponse, response_id) - if not response or response.user_id != db_user.id: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - if response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - poll = await _get_poll_with_questions(db, response.poll_id) - if not poll: - await callback.answer(texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), show_alert=True) - return - - question = next((q for q in poll.questions if q.id == question_id), None) - if not question: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - option = next((opt for opt in question.options if opt.id == option_id), None) - if not option: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - existing_answer = next((ans for ans in response.answers if ans.question_id == question_id), None) - if existing_answer: - await callback.answer(texts.t("POLL_OPTION_ALREADY_CHOSEN", "Ответ уже выбран.")) - return - - answer = PollAnswer( - response_id=response.id, - question_id=question.id, - option_id=option.id, - ) - db.add(answer) - await db.flush() - - response.answers.append(answer) - - next_question = _get_next_question(poll, response) - - if next_question: - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = False - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - return - - # Completed - response.current_question_id = None - response.is_completed = True - response.completed_at = datetime.utcnow() - - reward_text = "" - if poll.reward_enabled and poll.reward_amount_kopeks > 0 and not response.reward_given: - success = await add_user_balance( - db, - db_user, - poll.reward_amount_kopeks, - description=texts.t( - "POLL_REWARD_DESCRIPTION", - "Награда за участие в опросе '{title}'", - ).format(title=poll.title), - ) - if success: - response.reward_given = True - response.reward_amount_kopeks = poll.reward_amount_kopeks - reward_text = texts.t( - "POLL_REWARD_RECEIVED", - "\n\n🎁 На баланс зачислено: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - else: - logger.warning("Failed to add reward for poll %s to user %s", poll.id, db_user.telegram_id) - - if response.run_id: - run = await db.get(PollRun, response.run_id) - if run: - run.completed_count = (run.completed_count or 0) + 1 - - await db.commit() - - thank_you_text = ( - texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!") - + reward_text - ) - - try: - await callback.message.edit_text(thank_you_text) - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(thank_you_text) - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - - if response.chat_id and response.message_id: - asyncio.create_task( - _delete_message_after_delay(callback.bot, response.chat_id, response.message_id) - ) - - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(start_poll, F.data.startswith("poll_start_")) - dp.callback_query.register(answer_poll, F.data.startswith("poll_answer_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 480dd283..75e72bc0 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -96,17 +96,11 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"), - callback_data="admin_polls" - ) - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), @@ -978,54 +972,6 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data=f"poll_target_{poll_id}_all" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data=f"poll_target_{poll_id}_active" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data=f"poll_target_{poll_id}_trial" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data=f"poll_target_{poll_id}_no" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data=f"poll_target_{poll_id}_expiring" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data=f"poll_target_{poll_id}_expired" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data=f"poll_target_{poll_id}_active_zero" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data=f"poll_target_{poll_id}_trial_zero" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")] - ]) - - def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b9206713..eee72fe3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Polls", - "ADMIN_POLLS_TITLE": "📋 Polls", - "ADMIN_POLLS_DESCRIPTION": "Create polls and send them to users by broadcast categories.", - "ADMIN_POLLS_REWARD_ENABLED": "reward enabled", - "ADMIN_POLLS_REWARD_DISABLED": "no reward", - "ADMIN_POLLS_CREATE": "➕ Create poll", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Create a poll\n\nSend the poll title.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Title cannot be empty.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Send the poll description. HTML markup is supported.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Description cannot be empty.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Send the text of the first question.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Question text cannot be empty. Please resend it.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Send answer options, one per line (min 2, max 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Provide at least two answer options.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Maximum 10 options. Please send the list again.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Could not find question text. Start again by creating a question.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Add another question", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Configure reward", - "ADMIN_POLLS_CANCEL": "❌ Cancel", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Question saved. Choose the next action:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Send the next question text.", - "ADMIN_POLLS_NO_QUESTIONS": "— no questions yet —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Reward settings", - "ADMIN_POLLS_REWARD_STATUS": "Status: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Amount: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Total questions: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Disable reward", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Enable reward", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Change amount", - "ADMIN_POLLS_SAVE": "✅ Save poll", - "ADMIN_POLLS_ADD_MORE": "➕ Add another question", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Add at least one question before configuring the reward.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Enter reward amount in RUB (decimals allowed).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Could not parse the number. Use format 10 or 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Amount cannot be negative.", - "ADMIN_POLLS_MISSING_DATA": "Fill in title and description before saving.", - "ADMIN_POLLS_REWARD_ON": "Enabled", - "ADMIN_POLLS_REWARD_OFF": "Disabled", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Reward: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Reward: not provided", - "ADMIN_POLLS_CREATED": "✅ Poll saved!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", - "ADMIN_POLLS_OPEN": "📋 Open poll", - "ADMIN_POLLS_SAVE_ERROR": "❌ Failed to save poll. Please try again later.", - "ADMIN_POLLS_NOT_FOUND": "Poll not found or already removed.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Description:", - "ADMIN_POLLS_STATS_SENT": "Messages sent: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Questions:", - "ADMIN_POLLS_SEND": "🚀 Send", - "ADMIN_POLLS_STATS_BUTTON": "📊 Stats", - "ADMIN_POLLS_DELETE": "🗑️ Delete", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Choose a user segment for this poll.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Send", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Send confirmation", - "ADMIN_POLLS_CONFIRMATION_BODY": "Segment: {category}\nUsers: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "Users will receive an invite to complete the poll.", - "ADMIN_POLLS_NO_USERS": "No users matched the selected category.", - "ADMIN_POLLS_SENDING": "📨 Sending the poll...", - "ADMIN_POLLS_SENT": "✅ Sending completed!", - "ADMIN_POLLS_SENT_SUCCESS": "Successfully sent: {count}", - "ADMIN_POLLS_SENT_FAILED": "Failed deliveries: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Total recipients: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics", - "ADMIN_POLLS_STATS_RESPONDED": "Responses received: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "No answers yet.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.", - "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list", - "POLL_NOT_AVAILABLE": "Poll is not available.", - "POLL_ALREADY_PASSED": "You have already completed this poll.", - "POLL_NO_QUESTIONS": "No poll questions found.", - "POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.", - "POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}", - "POLL_COMPLETED": "🙏 Thanks for completing the poll!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 53ef53cd..ca6da8ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы", - "ADMIN_POLLS_TITLE": "📋 Опросы", - "ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.", - "ADMIN_POLLS_REWARD_ENABLED": "есть награда", - "ADMIN_POLLS_REWARD_DISABLED": "без награды", - "ADMIN_POLLS_CREATE": "➕ Создать опрос", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Создание опроса\n\nВведите заголовок опроса.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Укажите непустой заголовок.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Введите описание опроса. HTML-разметка поддерживается.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Описание не может быть пустым.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Отправьте текст первого вопроса опроса.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Укажите минимум два варианта ответа.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Настроить награду", - "ADMIN_POLLS_CANCEL": "❌ Отмена", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Вопрос добавлен. Выберите действие:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Отправьте текст следующего вопроса.", - "ADMIN_POLLS_NO_QUESTIONS": "— вопросы не добавлены —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Награда за участие", - "ADMIN_POLLS_REWARD_STATUS": "Статус: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Сумма: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Всего вопросов: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Отключить награду", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Включить награду", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Изменить сумму", - "ADMIN_POLLS_SAVE": "✅ Сохранить опрос", - "ADMIN_POLLS_ADD_MORE": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Добавьте хотя бы один вопрос перед настройкой награды.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Введите сумму награды в рублях (можно с копейками).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Сумма не может быть отрицательной.", - "ADMIN_POLLS_MISSING_DATA": "Заполните заголовок и описание перед сохранением.", - "ADMIN_POLLS_REWARD_ON": "Включена", - "ADMIN_POLLS_REWARD_OFF": "Отключена", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Награда: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Награда: не выдается", - "ADMIN_POLLS_CREATED": "✅ Опрос сохранён!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", - "ADMIN_POLLS_OPEN": "📋 К опросу", - "ADMIN_POLLS_SAVE_ERROR": "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - "ADMIN_POLLS_NOT_FOUND": "Опрос не найден или был удалён.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Описание:", - "ADMIN_POLLS_STATS_SENT": "Отправлено сообщений: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Вопросы:", - "ADMIN_POLLS_SEND": "🚀 Отправить", - "ADMIN_POLLS_STATS_BUTTON": "📊 Статистика", - "ADMIN_POLLS_DELETE": "🗑️ Удалить", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Выберите категорию пользователей для отправки опроса.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Отправить", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Подтверждение отправки", - "ADMIN_POLLS_CONFIRMATION_BODY": "Категория: {category}\nПользователей: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.", - "ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.", - "ADMIN_POLLS_SENDING": "📨 Отправляем опрос...", - "ADMIN_POLLS_SENT": "✅ Отправка завершена!", - "ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: {count}", - "ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса", - "ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.", - "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов", - "POLL_NOT_AVAILABLE": "Опрос недоступен.", - "POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.", - "POLL_NO_QUESTIONS": "Вопросы опроса не найдены.", - "POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.", - "POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}", - "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" } diff --git a/app/states.py b/app/states.py index b88ce142..549e314f 100644 --- a/app/states.py +++ b/app/states.py @@ -70,12 +70,6 @@ class AdminStates(StatesGroup): waiting_for_broadcast_media = State() confirming_broadcast = State() - creating_poll_title = State() - creating_poll_description = State() - creating_poll_question_text = State() - creating_poll_question_options = State() - creating_poll_reward_amount = State() - creating_promo_group_name = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() diff --git a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py deleted file mode 100644 index 31da4428..00000000 --- a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py +++ /dev/null @@ -1,238 +0,0 @@ -"""add polls tables""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.engine.reflection import Inspector - -revision: str = "a3f94c8b91dd" -down_revision: Union[str, None] = "8fd1e338eb45" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - -POLL_TABLE = "polls" -POLL_QUESTIONS_TABLE = "poll_questions" -POLL_OPTIONS_TABLE = "poll_options" -POLL_RUNS_TABLE = "poll_runs" -POLL_RESPONSES_TABLE = "poll_responses" -POLL_ANSWERS_TABLE = "poll_answers" - - -def _table_exists(inspector: Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_TABLE): - op.create_table( - POLL_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("title", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=False), - sa.Column( - "reward_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.create_table( - POLL_QUESTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.Text(), nullable=False), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_OPTIONS_TABLE): - op.create_table( - POLL_OPTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.String(length=255), nullable=False), - sa.ForeignKeyConstraint( - ["question_id"], - [f"{POLL_QUESTIONS_TABLE}.id"], - ondelete="CASCADE", - ), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RUNS_TABLE): - op.create_table( - POLL_RUNS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("target_type", sa.String(length=100), nullable=False), - sa.Column( - "status", - sa.String(length=50), - nullable=False, - server_default="scheduled", - ), - sa.Column( - "total_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "sent_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "failed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "completed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RESPONSES_TABLE): - op.create_table( - POLL_RESPONSES_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("run_id", sa.Integer(), nullable=True), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("current_question_id", sa.Integer(), nullable=True), - sa.Column("message_id", sa.Integer(), nullable=True), - sa.Column("chat_id", sa.BigInteger(), nullable=True), - sa.Column( - "is_completed", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_given", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"), - sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_ANSWERS_TABLE): - op.create_table( - POLL_ANSWERS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("response_id", sa.Integer(), nullable=False), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column("option_id", sa.Integer(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_ANSWERS_TABLE): - op.drop_table(POLL_ANSWERS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RESPONSES_TABLE): - op.drop_table(POLL_RESPONSES_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RUNS_TABLE): - op.drop_table(POLL_RUNS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_OPTIONS_TABLE): - op.drop_table(POLL_OPTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.drop_table(POLL_QUESTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_TABLE): - op.drop_table(POLL_TABLE) From 8956aafc9a96b3e30b9bca7ce2e8d0cbf2fb5c1d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:55:48 +0300 Subject: [PATCH 06/31] Fix poll handlers Bot type annotations --- app/bot.py | 5 +- app/database/models.py | 126 +- app/handlers/admin/polls.py | 1259 +++++++++++++++++ app/handlers/polls.py | 322 +++++ app/keyboards/admin.py | 56 +- app/localization/locales/en.json | 82 +- app/localization/locales/ru.json | 82 +- app/states.py | 6 + .../versions/a3f94c8b91dd_add_polls_tables.py | 238 ++++ 9 files changed, 2171 insertions(+), 5 deletions(-) create mode 100644 app/handlers/admin/polls.py create mode 100644 app/handlers/polls.py create mode 100644 migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index f21b7534..da9de335 100644 --- a/app/bot.py +++ b/app/bot.py @@ -17,7 +17,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets + referral, support, server_status, common, tickets, polls ) from app.handlers import simple_subscription from app.handlers.admin import ( @@ -48,6 +48,7 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, + polls as admin_polls, ) from app.handlers.stars_payments import register_stars_handlers @@ -134,6 +135,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: support.register_handlers(dp) server_status.register_handlers(dp) tickets.register_handlers(dp) + polls.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -161,6 +163,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) + admin_polls.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) diff --git a/app/database/models.py b/app/database/models.py index d296aba9..d1283fc1 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1061,7 +1061,7 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) @@ -1079,6 +1079,130 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") + +class Poll(Base): + __tablename__ = "polls" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=False) + reward_enabled = Column(Boolean, default=False, nullable=False) + reward_amount_kopeks = Column(Integer, default=0, nullable=False) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + questions = relationship( + "PollQuestion", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollQuestion.order", + ) + runs = relationship( + "PollRun", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollRun.created_at.desc()", + ) + responses = relationship( + "PollResponse", + back_populates="poll", + cascade="all, delete-orphan", + ) + creator = relationship("User", backref="created_polls") + + +class PollQuestion(Base): + __tablename__ = "poll_questions" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + order = Column(Integer, nullable=False, default=0) + text = Column(Text, nullable=False) + + poll = relationship("Poll", back_populates="questions") + options = relationship( + "PollOption", + back_populates="question", + cascade="all, delete-orphan", + order_by="PollOption.order", + ) + + +class PollOption(Base): + __tablename__ = "poll_options" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) + order = Column(Integer, nullable=False, default=0) + text = Column(String(255), nullable=False) + + question = relationship("PollQuestion", back_populates="options") + + +class PollRun(Base): + __tablename__ = "poll_runs" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + target_type = Column(String(100), nullable=False) + status = Column(String(50), default="scheduled", nullable=False) + total_count = Column(Integer, default=0) + sent_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + completed_count = Column(Integer, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + + poll = relationship("Poll", back_populates="runs") + creator = relationship("User", backref="created_poll_runs") + + +class PollResponse(Base): + __tablename__ = "poll_responses" + __table_args__ = ( + UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True) + message_id = Column(Integer, nullable=True) + chat_id = Column(BigInteger, nullable=True) + is_completed = Column(Boolean, default=False, nullable=False) + reward_given = Column(Boolean, default=False, nullable=False) + reward_amount_kopeks = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + + poll = relationship("Poll", back_populates="responses") + run = relationship("PollRun", backref="responses") + user = relationship("User", backref="poll_responses") + current_question = relationship("PollQuestion") + answers = relationship( + "PollAnswer", + back_populates="response", + cascade="all, delete-orphan", + ) + + +class PollAnswer(Base): + __tablename__ = "poll_answers" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) + option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + response = relationship("PollResponse", back_populates="answers") + question = relationship("PollQuestion") + option = relationship("PollOption") + class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py new file mode 100644 index 00000000..d7130a54 --- /dev/null +++ b/app/handlers/admin/polls.py @@ -0,0 +1,1259 @@ +import asyncio +import html +import logging +from datetime import datetime +from decimal import Decimal, InvalidOperation +from typing import List + +from aiogram import Bot, Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, + PollRun, + User, +) +from app.handlers.admin.messages import get_target_name, get_target_users +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + + +def _format_question_summary(index: int, question: PollQuestion) -> str: + escaped_question = html.escape(question.text) + lines = [f"{index}. {escaped_question}"] + for opt_index, option in enumerate(sorted(question.options, key=lambda o: o.order), start=1): + lines.append(f" {opt_index}) {html.escape(option.text)}") + return "\n".join(lines) + + +async def _get_poll(db: AsyncSession, poll_id: int) -> Poll | None: + stmt = ( + select(Poll) + .options( + selectinload(Poll.questions).selectinload(PollQuestion.options), + selectinload(Poll.runs), + ) + .where(Poll.id == poll_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +def _get_state_questions(data: dict) -> List[dict]: + return list(data.get("poll_questions", [])) + + +def _ensure_questions_present(questions: List[dict]) -> None: + if not questions: + raise ValueError("poll_without_questions") + + +@admin_required +@error_handler +async def show_polls_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + stmt = ( + select(Poll) + .options(selectinload(Poll.questions)) + .order_by(Poll.created_at.desc()) + ) + result = await db.execute(stmt) + polls = result.unique().scalars().all() + + text = ( + texts.t("ADMIN_POLLS_TITLE", "📋 Опросы") + + "\n\n" + + texts.t( + "ADMIN_POLLS_DESCRIPTION", + "Создавайте опросы и отправляйте их пользователям по категориям рассылок.", + ) + ) + + keyboard: list[list[types.InlineKeyboardButton]] = [] + for poll in polls: + question_count = len(poll.questions) + reward_label = ( + texts.t("ADMIN_POLLS_REWARD_ENABLED", "🎁 награда есть") + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_DISABLED", "без награды") + ) + button_text = f"📋 {poll.title} ({question_count}) — {reward_label}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_poll_{poll.id}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), + callback_data="admin_poll_create", + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_communications", + ) + ] + ) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_title) + await state.update_data( + poll_questions=[], + reward_enabled=False, + reward_amount_kopeks=0, + ) + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_ENTER_TITLE", + "🆕 Создание опроса\n\nВведите заголовок опроса.", + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_poll_title( + message: types.Message, + db_user: User, + state: FSMContext, +): + title = (message.text or "").strip() + texts = get_texts(db_user.language) + + if not title: + await message.answer( + texts.t("ADMIN_POLLS_ENTER_TITLE_RETRY", "❗️ Укажите непустой заголовок."), + ) + return + + await state.update_data(poll_title=title) + await state.set_state(AdminStates.creating_poll_description) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_DESCRIPTION", + "✍️ Введите описание опроса. HTML-разметка поддерживается.", + ) + ) + + +@admin_required +@error_handler +async def process_poll_description( + message: types.Message, + db_user: User, + state: FSMContext, +): + description = message.html_text or message.text or "" + description = description.strip() + texts = get_texts(db_user.language) + + if not description: + await message.answer( + texts.t("ADMIN_POLLS_ENTER_DESCRIPTION_RETRY", "❗️ Описание не может быть пустым."), + ) + return + + await state.update_data(poll_description=description) + await state.set_state(AdminStates.creating_poll_question_text) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION", + "❓ Отправьте текст первого вопроса опроса.", + ) + ) + + +@admin_required +@error_handler +async def process_poll_question_text( + message: types.Message, + db_user: User, + state: FSMContext, +): + question_text = (message.html_text or message.text or "").strip() + texts = get_texts(db_user.language) + + if not question_text: + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION_RETRY", + "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", + ) + ) + return + + await state.update_data(current_question_text=question_text) + await state.set_state(AdminStates.creating_poll_question_options) + await message.answer( + texts.t( + "ADMIN_POLLS_ENTER_OPTIONS", + "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", + ) + ) + + +@admin_required +@error_handler +async def process_poll_question_options( + message: types.Message, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + raw_options = (message.text or "").splitlines() + options = [opt.strip() for opt in raw_options if opt.strip()] + + if len(options) < 2: + await message.answer( + texts.t( + "ADMIN_POLLS_NEED_MORE_OPTIONS", + "❗️ Укажите минимум два варианта ответа.", + ) + ) + return + + if len(options) > 10: + await message.answer( + texts.t( + "ADMIN_POLLS_TOO_MANY_OPTIONS", + "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", + ) + ) + return + + data = await state.get_data() + question_text = data.get("current_question_text") + if not question_text: + await message.answer( + texts.t( + "ADMIN_POLLS_QUESTION_NOT_FOUND", + "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", + ) + ) + await state.set_state(AdminStates.creating_poll_question_text) + return + + questions = _get_state_questions(data) + questions.append({"text": question_text, "options": options}) + await state.update_data( + poll_questions=questions, + current_question_text=None, + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_ADD_QUESTION", "➕ Добавить ещё вопрос"), + callback_data="admin_poll_add_question", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CONFIGURE_REWARD", "🎁 Настроить награду"), + callback_data="admin_poll_reward_menu", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), + callback_data="admin_polls", + ) + ], + ] + ) + + await state.set_state(None) + await message.answer( + texts.t( + "ADMIN_POLLS_QUESTION_ADDED", + "✅ Вопрос добавлен. Выберите действие:", + ), + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def add_another_question( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_question_text) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_ENTER_QUESTION_NEXT", + "❓ Отправьте текст следующего вопроса.", + ) + ) + await callback.answer() + + +def _build_reward_menu(texts, data: dict) -> tuple[str, types.InlineKeyboardMarkup]: + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + questions = _get_state_questions(data) + + questions_summary = "\n".join( + f"{idx}. {html.escape(q['text'])}" for idx, q in enumerate(questions, start=1) + ) or texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —") + + reward_text = ( + texts.t("ADMIN_POLLS_REWARD_ON", "Включена") + if reward_enabled and reward_amount > 0 + else texts.t("ADMIN_POLLS_REWARD_OFF", "Отключена") + ) + reward_amount_label = texts.format_price(reward_amount) + + text = ( + texts.t("ADMIN_POLLS_REWARD_TITLE", "🎁 Награда за участие") + + "\n\n" + + texts.t("ADMIN_POLLS_REWARD_STATUS", "Статус: {status}" ).format(status=reward_text) + + "\n" + + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT", + "Сумма: {amount}", + ).format(amount=reward_amount_label) + + "\n\n" + + texts.t("ADMIN_POLLS_REWARD_QUESTIONS", "Всего вопросов: {count}").format(count=len(questions)) + + "\n" + + questions_summary + ) + + toggle_text = ( + texts.t("ADMIN_POLLS_REWARD_DISABLE", "🚫 Отключить награду") + if reward_enabled + else texts.t("ADMIN_POLLS_REWARD_ENABLE", "🔔 Включить награду") + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data="admin_poll_toggle_reward", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_REWARD_SET_AMOUNT", "💰 Изменить сумму"), + callback_data="admin_poll_reward_amount", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SAVE", "✅ Сохранить опрос"), + callback_data="admin_poll_save", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_ADD_MORE", "➕ Добавить ещё вопрос"), + callback_data="admin_poll_add_question", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), + callback_data="admin_polls", + ) + ], + ] + ) + + return text, keyboard + + +@admin_required +@error_handler +async def show_reward_menu( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + data = await state.get_data() + texts = get_texts(db_user.language) + try: + _ensure_questions_present(_get_state_questions(data)) + except ValueError: + await callback.answer( + texts.t( + "ADMIN_POLLS_NEED_QUESTION_FIRST", + "Добавьте хотя бы один вопрос перед настройкой награды.", + ), + show_alert=True, + ) + return + + text, keyboard = _build_reward_menu(texts, data) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def toggle_reward( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + data = await state.get_data() + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + + reward_enabled = not reward_enabled + if reward_enabled and reward_amount <= 0: + reward_amount = 1000 + + await state.update_data( + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount, + ) + + texts = get_texts(db_user.language) + text, keyboard = _build_reward_menu(texts, await state.get_data()) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await callback.answer() + + +@admin_required +@error_handler +async def request_reward_amount( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_poll_reward_amount) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT", + "💰 Введите сумму награды в рублях (можно с копейками).", + ) + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_reward_amount( + message: types.Message, + db_user: User, + state: FSMContext, +): + texts = get_texts(db_user.language) + raw_value = (message.text or "").replace(",", ".").strip() + + try: + value_decimal = Decimal(raw_value) + except (InvalidOperation, ValueError): + await message.answer( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_INVALID", + "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", + ) + ) + return + + if value_decimal < 0: + await message.answer( + texts.t( + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE", + "❗️ Сумма не может быть отрицательной.", + ) + ) + return + + amount_kopeks = int((value_decimal * 100).to_integral_value()) + await state.update_data( + reward_amount_kopeks=amount_kopeks, + reward_enabled=amount_kopeks > 0, + ) + await state.set_state(None) + + data = await state.get_data() + text, keyboard = _build_reward_menu(texts, data) + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + +@admin_required +@error_handler +async def save_poll( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(db_user.language) + + try: + _ensure_questions_present(_get_state_questions(data)) + except ValueError: + await callback.answer( + texts.t( + "ADMIN_POLLS_NEED_QUESTION_FIRST", + "Добавьте хотя бы один вопрос перед сохранением.", + ), + show_alert=True, + ) + return + + title = data.get("poll_title") + description = data.get("poll_description") + questions = _get_state_questions(data) + reward_enabled = bool(data.get("reward_enabled")) + reward_amount = int(data.get("reward_amount_kopeks") or 0) + + if not title or not description: + await callback.answer( + texts.t( + "ADMIN_POLLS_MISSING_DATA", + "Заполните заголовок и описание перед сохранением.", + ), + show_alert=True, + ) + return + + poll = Poll( + title=title, + description=description, + reward_enabled=reward_enabled and reward_amount > 0, + reward_amount_kopeks=reward_amount if reward_amount > 0 else 0, + created_by=db_user.id, + created_at=datetime.utcnow(), + ) + + try: + db.add(poll) + await db.flush() + + for q_index, question_data in enumerate(questions, start=1): + question = PollQuestion( + poll_id=poll.id, + text=question_data["text"], + order=q_index, + ) + db.add(question) + await db.flush() + + for opt_index, option_text in enumerate(question_data["options"], start=1): + option = PollOption( + question_id=question.id, + text=option_text, + order=opt_index, + ) + db.add(option) + + await db.commit() + await state.clear() + + poll = await _get_poll(db, poll.id) + question_lines = [ + _format_question_summary(idx, question) + for idx, question in enumerate(poll.questions, start=1) + ] + reward_info = ( + texts.t( + "ADMIN_POLLS_REWARD_SUMMARY", + "🎁 Награда: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") + ) + + summary_text = ( + texts.t("ADMIN_POLLS_CREATED", "✅ Опрос сохранён!") + + "\n\n" + + f"{html.escape(poll.title)}\n" + + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions)) + + "\n" + + reward_info + + "\n\n" + + "\n".join(question_lines) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_OPEN", "📋 К опросу"), + callback_data=f"admin_poll_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_polls", + ) + ], + ] + ) + + await callback.message.edit_text( + summary_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + except Exception as exc: # pragma: no cover - defensive logging + await db.rollback() + logger.exception("Failed to create poll: %s", exc) + await callback.answer( + texts.t( + "ADMIN_POLLS_SAVE_ERROR", + "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", + ), + show_alert=True, + ) + + +@admin_required +@error_handler +async def show_poll_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден или был удалён."), + show_alert=True, + ) + return + + question_lines = [ + _format_question_summary(idx, question) + for idx, question in enumerate(poll.questions, start=1) + ] + + runs_total = sum(run.sent_count for run in poll.runs) + completions = await db.scalar( + select(func.count(PollResponse.id)).where( + PollResponse.poll_id == poll.id, + PollResponse.is_completed.is_(True), + ) + ) or 0 + + reward_info = ( + texts.t( + "ADMIN_POLLS_REWARD_SUMMARY", + "🎁 Награда: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + if poll.reward_enabled and poll.reward_amount_kopeks > 0 + else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") + ) + + description_preview = html.escape(poll.description) + + text = ( + f"📋 {html.escape(poll.title)}\n\n" + + texts.t("ADMIN_POLLS_DESCRIPTION_LABEL", "Описание:") + + f"\n{description_preview}\n\n" + + texts.t( + "ADMIN_POLLS_STATS_SENT", + "Отправлено сообщений: {count}", + ).format(count=runs_total) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_COMPLETED", + "Завершили опрос: {count}", + ).format(count=completions) + + "\n" + + reward_info + + "\n\n" + + texts.t("ADMIN_POLLS_QUESTIONS_LIST", "Вопросы:") + + "\n" + + ("\n".join(question_lines) if question_lines else texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —")) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND", "🚀 Отправить"), + callback_data=f"admin_poll_send_{poll.id}", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), + callback_data=f"admin_poll_stats_{poll.id}", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"admin_poll_delete_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_polls", + ) + ], + ] + ) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_target_selection( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll or not poll.questions: + await callback.answer( + texts.t( + "ADMIN_POLLS_NO_QUESTIONS", + "Сначала добавьте вопросы к опросу, чтобы отправлять его пользователям.", + ), + show_alert=True, + ) + return + + from app.keyboards.admin import get_poll_target_keyboard + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_SELECT_TARGET", + "🎯 Выберите категорию пользователей для отправки опроса.", + ), + reply_markup=get_poll_target_keyboard(poll.id, db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def preview_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, _, poll_id_str, target = callback.data.split("_", 3) + poll_id = int(poll_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + users = await get_target_users(db, target) + target_name = get_target_name(target) + + confirm_keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CONFIRM_SEND", "✅ Отправить"), + callback_data=f"admin_poll_send_confirm_{poll_id}_{target}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_send_{poll_id}", + ) + ], + ] + ) + + text = ( + texts.t("ADMIN_POLLS_CONFIRMATION_TITLE", "📨 Подтверждение отправки") + + "\n\n" + + texts.t( + "ADMIN_POLLS_CONFIRMATION_BODY", + "Категория: {category}\nПользователей: {count}", + ).format(category=target_name, count=len(users)) + + "\n\n" + + texts.t( + "ADMIN_POLLS_CONFIRMATION_HINT", + "После отправки пользователи получат приглашение пройти опрос.", + ) + ) + + await callback.message.edit_text( + text, + reply_markup=confirm_keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +async def _send_poll_invitation( + bot: Bot, + poll: Poll, + run: PollRun, + users: list, +) -> tuple[int, int]: + sent_count = 0 + failed_count = 0 + + invite_text = ( + f"📋 {html.escape(poll.title)}\n\n" + f"{poll.description}\n\n" + "📝 Нажмите кнопку ниже, чтобы пройти опрос." + ) + + for index, user in enumerate(users, start=1): + try: + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="📝 Пройти опрос", + callback_data=f"poll_start_{poll.id}_{run.id}", + ) + ] + ] + ) + await bot.send_message( + chat_id=user.telegram_id, + text=invite_text, + reply_markup=keyboard, + ) + sent_count += 1 + except Exception as exc: # pragma: no cover - defensive logging + failed_count += 1 + logger.warning( + "Failed to send poll %s to user %s: %s", + poll.id, + getattr(user, "telegram_id", "unknown"), + exc, + ) + if index % 25 == 0: + await asyncio.sleep(0.5) + + return sent_count, failed_count + + +@admin_required +@error_handler +async def confirm_poll_sending( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + parts = callback.data.split("_") + if len(parts) < 6: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + try: + poll_id = int(parts[4]) + except ValueError: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + target = "_".join(parts[5:]) + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + users = await get_target_users(db, target) + if not users: + await callback.answer( + texts.t( + "ADMIN_POLLS_NO_USERS", + "Подходящих пользователей не найдено для выбранной категории.", + ), + show_alert=True, + ) + return + + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SENDING", "📨 Отправляем опрос..."), + ) + + run = PollRun( + poll_id=poll.id, + target_type=target, + status="in_progress", + total_count=len(users), + created_by=db_user.id, + created_at=datetime.utcnow(), + started_at=datetime.utcnow(), + ) + db.add(run) + await db.flush() + + sent_count, failed_count = await _send_poll_invitation(callback.bot, poll, run, users) + + run.sent_count = sent_count + run.failed_count = failed_count + run.status = "completed" + run.completed_at = datetime.utcnow() + + await db.commit() + + result_text = ( + texts.t("ADMIN_POLLS_SENT", "✅ Отправка завершена!") + + "\n\n" + + texts.t("ADMIN_POLLS_SENT_SUCCESS", "Успешно отправлено: {count}").format(count=sent_count) + + "\n" + + texts.t("ADMIN_POLLS_SENT_FAILED", "Ошибок доставки: {count}").format(count=failed_count) + + "\n" + + texts.t("ADMIN_POLLS_SENT_TOTAL", "Всего пользователей: {count}").format(count=len(users)) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), + callback_data=f"admin_poll_stats_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + result_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + total_responses = await db.scalar( + select(func.count(PollResponse.id)).where(PollResponse.poll_id == poll.id) + ) or 0 + completed_responses = await db.scalar( + select(func.count(PollResponse.id)).where( + PollResponse.poll_id == poll.id, + PollResponse.is_completed.is_(True), + ) + ) or 0 + reward_sum = await db.scalar( + select(func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0)).where( + PollResponse.poll_id == poll.id, + PollResponse.reward_given.is_(True), + ) + ) or 0 + + runs_total = await db.scalar( + select(func.coalesce(func.sum(PollRun.sent_count), 0)).where(PollRun.poll_id == poll.id) + ) or 0 + + answers_stmt = ( + select(PollAnswer.question_id, PollAnswer.option_id, func.count(PollAnswer.id)) + .join(PollResponse, PollResponse.id == PollAnswer.response_id) + .where(PollResponse.poll_id == poll.id) + .group_by(PollAnswer.question_id, PollAnswer.option_id) + ) + answers_result = await db.execute(answers_stmt) + answer_counts = { + (question_id, option_id): count + for question_id, option_id, count in answers_result.all() + } + + question_lines = [] + for question in sorted(poll.questions, key=lambda q: q.order): + total_answers_for_question = sum( + answer_counts.get((question.id, option.id), 0) + for option in question.options + ) or 0 + question_lines.append(f"{html.escape(question.text)}") + for option in sorted(question.options, key=lambda o: o.order): + option_count = answer_counts.get((question.id, option.id), 0) + percent = ( + round(option_count / total_answers_for_question * 100, 1) + if total_answers_for_question + else 0 + ) + question_lines.append( + texts.t( + "ADMIN_POLLS_STATS_OPTION", + "• {text} — {count} ({percent}%)", + ).format( + text=html.escape(option.text), + count=option_count, + percent=percent, + ) + ) + question_lines.append("") + + text = ( + texts.t("ADMIN_POLLS_STATS_TITLE", "📊 Статистика опроса") + + "\n\n" + + f"{html.escape(poll.title)}\n" + + texts.t("ADMIN_POLLS_STATS_SENT", "Сообщений отправлено: {count}").format(count=runs_total) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_RESPONDED", + "Ответов получено: {count}", + ).format(count=total_responses) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_COMPLETED_LABEL", + "Прошли до конца: {count}", + ).format(count=completed_responses) + + "\n" + + texts.t( + "ADMIN_POLLS_STATS_REWARD_TOTAL", + "Выдано наград: {amount}", + ).format(amount=texts.format_price(reward_sum)) + + "\n\n" + + ("\n".join(question_lines).strip() or texts.t("ADMIN_POLLS_STATS_NO_DATA", "Ответов пока нет.")) + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def ask_delete_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll(db, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), + show_alert=True, + ) + return + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE_CONFIRM", "🗑️ Удалить"), + callback_data=f"admin_poll_delete_confirm_{poll.id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_poll_{poll.id}", + ) + ], + ] + ) + + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_DELETE_PROMPT", + "❓ Удалить опрос? Это действие нельзя отменить.", + ), + reply_markup=keyboard, + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + poll_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await db.get(Poll, poll_id) + if not poll: + await callback.answer( + texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос уже удалён."), + show_alert=True, + ) + return + + await db.delete(poll) + await db.commit() + + await callback.message.edit_text( + texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_BACK_TO_LIST", "⬅️ К списку опросов"), + callback_data="admin_polls", + ) + ] + ] + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(show_polls_menu, F.data == "admin_polls") + dp.callback_query.register(start_poll_creation, F.data == "admin_poll_create") + dp.message.register(process_poll_title, AdminStates.creating_poll_title) + dp.message.register(process_poll_description, AdminStates.creating_poll_description) + dp.message.register(process_poll_question_text, AdminStates.creating_poll_question_text) + dp.message.register(process_poll_question_options, AdminStates.creating_poll_question_options) + dp.message.register(process_reward_amount, AdminStates.creating_poll_reward_amount) + + dp.callback_query.register(add_another_question, F.data == "admin_poll_add_question") + dp.callback_query.register(show_reward_menu, F.data == "admin_poll_reward_menu") + dp.callback_query.register(toggle_reward, F.data == "admin_poll_toggle_reward") + dp.callback_query.register(request_reward_amount, F.data == "admin_poll_reward_amount") + dp.callback_query.register(save_poll, F.data == "admin_poll_save") + + dp.callback_query.register( + show_poll_details, + F.data.regexp(r"^admin_poll_(?!send_|stats_|delete_|create).+"), + ) + dp.callback_query.register( + show_poll_target_selection, + F.data.regexp(r"^admin_poll_send_\\d+$"), + ) + dp.callback_query.register(preview_poll_target, F.data.startswith("poll_target_")) + dp.callback_query.register( + confirm_poll_sending, + F.data.regexp(r"^admin_poll_send_confirm_\\d+_.+"), + ) + dp.callback_query.register( + show_poll_stats, + F.data.regexp(r"^admin_poll_stats_\\d+$"), + ) + dp.callback_query.register( + ask_delete_poll, + F.data.regexp(r"^admin_poll_delete_\\d+$"), + ) + dp.callback_query.register( + delete_poll, + F.data.regexp(r"^admin_poll_delete_confirm_\\d+$"), + ) diff --git a/app/handlers/polls.py b/app/handlers/polls.py new file mode 100644 index 00000000..491519ca --- /dev/null +++ b/app/handlers/polls.py @@ -0,0 +1,322 @@ +import asyncio +import html +import logging +from datetime import datetime + +from aiogram import Bot, Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.user import add_user_balance +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, + PollRun, + User, +) +from app.localization.texts import get_texts +from app.utils.decorators import error_handler + +logger = logging.getLogger(__name__) + + +async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None: + stmt = ( + select(Poll) + .options(selectinload(Poll.questions).selectinload(PollQuestion.options)) + .where(Poll.id == poll_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +async def _get_response( + db: AsyncSession, + poll_id: int, + user_id: int, +) -> PollResponse | None: + stmt = ( + select(PollResponse) + .options(selectinload(PollResponse.answers)) + .where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +def _get_next_question( + poll: Poll, + response: PollResponse, +) -> PollQuestion | None: + questions = sorted(poll.questions, key=lambda q: q.order) + answered_ids = {answer.question_id for answer in response.answers} + for question in questions: + if question.id not in answered_ids: + return question + return None + + +async def _delete_message_after_delay(bot: Bot, chat_id: int, message_id: int) -> None: + await asyncio.sleep(10) + try: + await bot.delete_message(chat_id, message_id) + except TelegramBadRequest: + pass + except Exception as exc: # pragma: no cover - defensive logging + logger.warning("Failed to delete poll message %s: %s", message_id, exc) + + +def _build_question_text( + poll: Poll, + question: PollQuestion, + question_index: int, + total_questions: int, + include_description: bool, +) -> str: + header = f"📋 {html.escape(poll.title)}\n\n" + body = "" + if include_description: + body += f"{poll.description}\n\n" + body += ( + f"❓ {question_index}/{total_questions}\n" + f"{html.escape(question.text)}" + ) + return header + body + + +def _build_question_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: + buttons = [] + for option in sorted(question.options, key=lambda o: o.order): + buttons.append( + [ + types.InlineKeyboardButton( + text=option.text, + callback_data=f"poll_answer_{response_id}_{question.id}_{option.id}", + ) + ] + ) + return types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +@error_handler +async def start_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, poll_id_str, run_id_str = callback.data.split("_", 2) + poll_id = int(poll_id_str) + run_id = int(run_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + poll = await _get_poll_with_questions(db, poll_id) + if not poll or not poll.questions: + await callback.answer( + texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), + show_alert=True, + ) + return + + response = await _get_response(db, poll.id, db_user.id) + if response and response.is_completed: + await callback.answer( + texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), + show_alert=True, + ) + return + + if not response: + response = PollResponse( + poll_id=poll.id, + run_id=run_id, + user_id=db_user.id, + message_id=callback.message.message_id, + chat_id=callback.message.chat.id, + created_at=datetime.utcnow(), + ) + db.add(response) + await db.flush() + else: + response.message_id = callback.message.message_id + response.chat_id = callback.message.chat.id + await db.flush() + + next_question = _get_next_question(poll, response) + if not next_question: + await callback.answer( + texts.t("POLL_NO_QUESTIONS", "Вопросы опроса не найдены."), + show_alert=True, + ) + return + + response.current_question_id = next_question.id + await db.commit() + + question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 + total_questions = len(poll.questions) + include_description = len(response.answers) == 0 + text = _build_question_text(poll, next_question, question_index, total_questions, include_description) + keyboard = _build_question_keyboard(response.id, next_question) + + try: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + await callback.answer() + + +@error_handler +async def answer_poll( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + _, response_id_str, question_id_str, option_id_str = callback.data.split("_", 3) + response_id = int(response_id_str) + question_id = int(question_id_str) + option_id = int(option_id_str) + except (ValueError, IndexError): + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + response = await db.get(PollResponse, response_id) + if not response or response.user_id != db_user.id: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + if response.is_completed: + await callback.answer( + texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), + show_alert=True, + ) + return + + poll = await _get_poll_with_questions(db, response.poll_id) + if not poll: + await callback.answer(texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), show_alert=True) + return + + question = next((q for q in poll.questions if q.id == question_id), None) + if not question: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + option = next((opt for opt in question.options if opt.id == option_id), None) + if not option: + await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) + return + + existing_answer = next((ans for ans in response.answers if ans.question_id == question_id), None) + if existing_answer: + await callback.answer(texts.t("POLL_OPTION_ALREADY_CHOSEN", "Ответ уже выбран.")) + return + + answer = PollAnswer( + response_id=response.id, + question_id=question.id, + option_id=option.id, + ) + db.add(answer) + await db.flush() + + response.answers.append(answer) + + next_question = _get_next_question(poll, response) + + if next_question: + response.current_question_id = next_question.id + await db.commit() + + question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 + total_questions = len(poll.questions) + include_description = False + text = _build_question_text(poll, next_question, question_index, total_questions, include_description) + keyboard = _build_question_keyboard(response.id, next_question) + + try: + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + await callback.answer() + return + + # Completed + response.current_question_id = None + response.is_completed = True + response.completed_at = datetime.utcnow() + + reward_text = "" + if poll.reward_enabled and poll.reward_amount_kopeks > 0 and not response.reward_given: + success = await add_user_balance( + db, + db_user, + poll.reward_amount_kopeks, + description=texts.t( + "POLL_REWARD_DESCRIPTION", + "Награда за участие в опросе '{title}'", + ).format(title=poll.title), + ) + if success: + response.reward_given = True + response.reward_amount_kopeks = poll.reward_amount_kopeks + reward_text = texts.t( + "POLL_REWARD_RECEIVED", + "\n\n🎁 На баланс зачислено: {amount}", + ).format(amount=texts.format_price(poll.reward_amount_kopeks)) + else: + logger.warning("Failed to add reward for poll %s to user %s", poll.id, db_user.telegram_id) + + if response.run_id: + run = await db.get(PollRun, response.run_id) + if run: + run.completed_count = (run.completed_count or 0) + 1 + + await db.commit() + + thank_you_text = ( + texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!") + + reward_text + ) + + try: + await callback.message.edit_text(thank_you_text) + new_message = None + except TelegramBadRequest: + new_message = await callback.message.answer(thank_you_text) + if new_message: + response.message_id = new_message.message_id + response.chat_id = new_message.chat.id + await db.commit() + + if response.chat_id and response.message_id: + asyncio.create_task( + _delete_message_after_delay(callback.bot, response.chat_id, response.message_id) + ) + + await callback.answer() + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(start_poll, F.data.startswith("poll_start_")) + dp.callback_query.register(answer_poll, F.data.startswith("poll_answer_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 75e72bc0..480dd283 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -96,11 +96,17 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"), + callback_data="admin_polls" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), @@ -972,6 +978,54 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), + callback_data=f"poll_target_{poll_id}_all" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), + callback_data=f"poll_target_{poll_id}_active" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), + callback_data=f"poll_target_{poll_id}_trial" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), + callback_data=f"poll_target_{poll_id}_no" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), + callback_data=f"poll_target_{poll_id}_expiring" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), + callback_data=f"poll_target_{poll_id}_expired" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), + callback_data=f"poll_target_{poll_id}_active_zero" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), + callback_data=f"poll_target_{poll_id}_trial_zero" + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")] + ]) + + def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index eee72fe3..b9206713 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,5 +1375,85 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", + "ADMIN_COMMUNICATIONS_POLLS": "📋 Polls", + "ADMIN_POLLS_TITLE": "📋 Polls", + "ADMIN_POLLS_DESCRIPTION": "Create polls and send them to users by broadcast categories.", + "ADMIN_POLLS_REWARD_ENABLED": "reward enabled", + "ADMIN_POLLS_REWARD_DISABLED": "no reward", + "ADMIN_POLLS_CREATE": "➕ Create poll", + "ADMIN_POLLS_ENTER_TITLE": "🆕 Create a poll\n\nSend the poll title.", + "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Title cannot be empty.", + "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Send the poll description. HTML markup is supported.", + "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Description cannot be empty.", + "ADMIN_POLLS_ENTER_QUESTION": "❓ Send the text of the first question.", + "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Question text cannot be empty. Please resend it.", + "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Send answer options, one per line (min 2, max 10).", + "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Provide at least two answer options.", + "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Maximum 10 options. Please send the list again.", + "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Could not find question text. Start again by creating a question.", + "ADMIN_POLLS_ADD_QUESTION": "➕ Add another question", + "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Configure reward", + "ADMIN_POLLS_CANCEL": "❌ Cancel", + "ADMIN_POLLS_QUESTION_ADDED": "✅ Question saved. Choose the next action:", + "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Send the next question text.", + "ADMIN_POLLS_NO_QUESTIONS": "— no questions yet —", + "ADMIN_POLLS_REWARD_TITLE": "🎁 Reward settings", + "ADMIN_POLLS_REWARD_STATUS": "Status: {status}", + "ADMIN_POLLS_REWARD_AMOUNT": "Amount: {amount}", + "ADMIN_POLLS_REWARD_QUESTIONS": "Total questions: {count}", + "ADMIN_POLLS_REWARD_DISABLE": "🚫 Disable reward", + "ADMIN_POLLS_REWARD_ENABLE": "🔔 Enable reward", + "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Change amount", + "ADMIN_POLLS_SAVE": "✅ Save poll", + "ADMIN_POLLS_ADD_MORE": "➕ Add another question", + "ADMIN_POLLS_NEED_QUESTION_FIRST": "Add at least one question before configuring the reward.", + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Enter reward amount in RUB (decimals allowed).", + "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Could not parse the number. Use format 10 or 12.5", + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Amount cannot be negative.", + "ADMIN_POLLS_MISSING_DATA": "Fill in title and description before saving.", + "ADMIN_POLLS_REWARD_ON": "Enabled", + "ADMIN_POLLS_REWARD_OFF": "Disabled", + "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Reward: {amount}", + "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Reward: not provided", + "ADMIN_POLLS_CREATED": "✅ Poll saved!", + "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", + "ADMIN_POLLS_OPEN": "📋 Open poll", + "ADMIN_POLLS_SAVE_ERROR": "❌ Failed to save poll. Please try again later.", + "ADMIN_POLLS_NOT_FOUND": "Poll not found or already removed.", + "ADMIN_POLLS_DESCRIPTION_LABEL": "Description:", + "ADMIN_POLLS_STATS_SENT": "Messages sent: {count}", + "ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: {count}", + "ADMIN_POLLS_QUESTIONS_LIST": "Questions:", + "ADMIN_POLLS_SEND": "🚀 Send", + "ADMIN_POLLS_STATS_BUTTON": "📊 Stats", + "ADMIN_POLLS_DELETE": "🗑️ Delete", + "ADMIN_POLLS_SELECT_TARGET": "🎯 Choose a user segment for this poll.", + "ADMIN_POLLS_CONFIRM_SEND": "✅ Send", + "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Send confirmation", + "ADMIN_POLLS_CONFIRMATION_BODY": "Segment: {category}\nUsers: {count}", + "ADMIN_POLLS_CONFIRMATION_HINT": "Users will receive an invite to complete the poll.", + "ADMIN_POLLS_NO_USERS": "No users matched the selected category.", + "ADMIN_POLLS_SENDING": "📨 Sending the poll...", + "ADMIN_POLLS_SENT": "✅ Sending completed!", + "ADMIN_POLLS_SENT_SUCCESS": "Successfully sent: {count}", + "ADMIN_POLLS_SENT_FAILED": "Failed deliveries: {count}", + "ADMIN_POLLS_SENT_TOTAL": "Total recipients: {count}", + "ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics", + "ADMIN_POLLS_STATS_RESPONDED": "Responses received: {count}", + "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: {count}", + "ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: {amount}", + "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", + "ADMIN_POLLS_STATS_NO_DATA": "No answers yet.", + "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete", + "ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.", + "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", + "ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list", + "POLL_NOT_AVAILABLE": "Poll is not available.", + "POLL_ALREADY_PASSED": "You have already completed this poll.", + "POLL_NO_QUESTIONS": "No poll questions found.", + "POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.", + "POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'", + "POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}", + "POLL_COMPLETED": "🙏 Thanks for completing the poll!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ca6da8ca..53ef53cd 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,5 +1375,85 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", + "ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы", + "ADMIN_POLLS_TITLE": "📋 Опросы", + "ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.", + "ADMIN_POLLS_REWARD_ENABLED": "есть награда", + "ADMIN_POLLS_REWARD_DISABLED": "без награды", + "ADMIN_POLLS_CREATE": "➕ Создать опрос", + "ADMIN_POLLS_ENTER_TITLE": "🆕 Создание опроса\n\nВведите заголовок опроса.", + "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Укажите непустой заголовок.", + "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Введите описание опроса. HTML-разметка поддерживается.", + "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Описание не может быть пустым.", + "ADMIN_POLLS_ENTER_QUESTION": "❓ Отправьте текст первого вопроса опроса.", + "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", + "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", + "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Укажите минимум два варианта ответа.", + "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", + "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", + "ADMIN_POLLS_ADD_QUESTION": "➕ Добавить ещё вопрос", + "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Настроить награду", + "ADMIN_POLLS_CANCEL": "❌ Отмена", + "ADMIN_POLLS_QUESTION_ADDED": "✅ Вопрос добавлен. Выберите действие:", + "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Отправьте текст следующего вопроса.", + "ADMIN_POLLS_NO_QUESTIONS": "— вопросы не добавлены —", + "ADMIN_POLLS_REWARD_TITLE": "🎁 Награда за участие", + "ADMIN_POLLS_REWARD_STATUS": "Статус: {status}", + "ADMIN_POLLS_REWARD_AMOUNT": "Сумма: {amount}", + "ADMIN_POLLS_REWARD_QUESTIONS": "Всего вопросов: {count}", + "ADMIN_POLLS_REWARD_DISABLE": "🚫 Отключить награду", + "ADMIN_POLLS_REWARD_ENABLE": "🔔 Включить награду", + "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Изменить сумму", + "ADMIN_POLLS_SAVE": "✅ Сохранить опрос", + "ADMIN_POLLS_ADD_MORE": "➕ Добавить ещё вопрос", + "ADMIN_POLLS_NEED_QUESTION_FIRST": "Добавьте хотя бы один вопрос перед настройкой награды.", + "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Введите сумму награды в рублях (можно с копейками).", + "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", + "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Сумма не может быть отрицательной.", + "ADMIN_POLLS_MISSING_DATA": "Заполните заголовок и описание перед сохранением.", + "ADMIN_POLLS_REWARD_ON": "Включена", + "ADMIN_POLLS_REWARD_OFF": "Отключена", + "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Награда: {amount}", + "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Награда: не выдается", + "ADMIN_POLLS_CREATED": "✅ Опрос сохранён!", + "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", + "ADMIN_POLLS_OPEN": "📋 К опросу", + "ADMIN_POLLS_SAVE_ERROR": "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", + "ADMIN_POLLS_NOT_FOUND": "Опрос не найден или был удалён.", + "ADMIN_POLLS_DESCRIPTION_LABEL": "Описание:", + "ADMIN_POLLS_STATS_SENT": "Отправлено сообщений: {count}", + "ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: {count}", + "ADMIN_POLLS_QUESTIONS_LIST": "Вопросы:", + "ADMIN_POLLS_SEND": "🚀 Отправить", + "ADMIN_POLLS_STATS_BUTTON": "📊 Статистика", + "ADMIN_POLLS_DELETE": "🗑️ Удалить", + "ADMIN_POLLS_SELECT_TARGET": "🎯 Выберите категорию пользователей для отправки опроса.", + "ADMIN_POLLS_CONFIRM_SEND": "✅ Отправить", + "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Подтверждение отправки", + "ADMIN_POLLS_CONFIRMATION_BODY": "Категория: {category}\nПользователей: {count}", + "ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.", + "ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.", + "ADMIN_POLLS_SENDING": "📨 Отправляем опрос...", + "ADMIN_POLLS_SENT": "✅ Отправка завершена!", + "ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: {count}", + "ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: {count}", + "ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: {count}", + "ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса", + "ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: {count}", + "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: {count}", + "ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: {amount}", + "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", + "ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.", + "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить", + "ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.", + "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", + "ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов", + "POLL_NOT_AVAILABLE": "Опрос недоступен.", + "POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.", + "POLL_NO_QUESTIONS": "Вопросы опроса не найдены.", + "POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.", + "POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'", + "POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}", + "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!" } diff --git a/app/states.py b/app/states.py index 549e314f..b88ce142 100644 --- a/app/states.py +++ b/app/states.py @@ -70,6 +70,12 @@ class AdminStates(StatesGroup): waiting_for_broadcast_media = State() confirming_broadcast = State() + creating_poll_title = State() + creating_poll_description = State() + creating_poll_question_text = State() + creating_poll_question_options = State() + creating_poll_reward_amount = State() + creating_promo_group_name = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() diff --git a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py new file mode 100644 index 00000000..31da4428 --- /dev/null +++ b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py @@ -0,0 +1,238 @@ +"""add polls tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + +revision: str = "a3f94c8b91dd" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +POLL_TABLE = "polls" +POLL_QUESTIONS_TABLE = "poll_questions" +POLL_OPTIONS_TABLE = "poll_options" +POLL_RUNS_TABLE = "poll_runs" +POLL_RESPONSES_TABLE = "poll_responses" +POLL_ANSWERS_TABLE = "poll_answers" + + +def _table_exists(inspector: Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_TABLE): + op.create_table( + POLL_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column( + "reward_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_QUESTIONS_TABLE): + op.create_table( + POLL_QUESTIONS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("text", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_OPTIONS_TABLE): + op.create_table( + POLL_OPTIONS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("text", sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint( + ["question_id"], + [f"{POLL_QUESTIONS_TABLE}.id"], + ondelete="CASCADE", + ), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_RUNS_TABLE): + op.create_table( + POLL_RUNS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("target_type", sa.String(length=100), nullable=False), + sa.Column( + "status", + sa.String(length=50), + nullable=False, + server_default="scheduled", + ), + sa.Column( + "total_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "sent_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "failed_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "completed_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_RESPONSES_TABLE): + op.create_table( + POLL_RESPONSES_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("run_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("current_question_id", sa.Integer(), nullable=True), + sa.Column("message_id", sa.Integer(), nullable=True), + sa.Column("chat_id", sa.BigInteger(), nullable=True), + sa.Column( + "is_completed", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_given", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"), + sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + inspector = sa.inspect(bind) + + if not _table_exists(inspector, POLL_ANSWERS_TABLE): + op.create_table( + POLL_ANSWERS_TABLE, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("response_id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("option_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_ANSWERS_TABLE): + op.drop_table(POLL_ANSWERS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_RESPONSES_TABLE): + op.drop_table(POLL_RESPONSES_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_RUNS_TABLE): + op.drop_table(POLL_RUNS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_OPTIONS_TABLE): + op.drop_table(POLL_OPTIONS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_QUESTIONS_TABLE): + op.drop_table(POLL_QUESTIONS_TABLE) + inspector = sa.inspect(bind) + + if _table_exists(inspector, POLL_TABLE): + op.drop_table(POLL_TABLE) From fc65b62d65bfa6e536368185525cda44fba76000 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:03:09 +0300 Subject: [PATCH 07/31] Revert "Fix aiogram Bot usage in poll handlers" --- app/bot.py | 5 +- app/database/models.py | 126 +- app/handlers/admin/polls.py | 1259 ----------------- app/handlers/polls.py | 322 ----- app/keyboards/admin.py | 56 +- app/localization/locales/en.json | 82 +- app/localization/locales/ru.json | 82 +- app/states.py | 6 - .../versions/a3f94c8b91dd_add_polls_tables.py | 238 ---- 9 files changed, 5 insertions(+), 2171 deletions(-) delete mode 100644 app/handlers/admin/polls.py delete mode 100644 app/handlers/polls.py delete mode 100644 migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index da9de335..f21b7534 100644 --- a/app/bot.py +++ b/app/bot.py @@ -17,7 +17,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets, polls + referral, support, server_status, common, tickets ) from app.handlers import simple_subscription from app.handlers.admin import ( @@ -48,7 +48,6 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, - polls as admin_polls, ) from app.handlers.stars_payments import register_stars_handlers @@ -135,7 +134,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: support.register_handlers(dp) server_status.register_handlers(dp) tickets.register_handlers(dp) - polls.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -163,7 +161,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) - admin_polls.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) diff --git a/app/database/models.py b/app/database/models.py index d1283fc1..d296aba9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1061,7 +1061,7 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) @@ -1079,130 +1079,6 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") - -class Poll(Base): - __tablename__ = "polls" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=False) - reward_enabled = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - questions = relationship( - "PollQuestion", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollQuestion.order", - ) - runs = relationship( - "PollRun", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollRun.created_at.desc()", - ) - responses = relationship( - "PollResponse", - back_populates="poll", - cascade="all, delete-orphan", - ) - creator = relationship("User", backref="created_polls") - - -class PollQuestion(Base): - __tablename__ = "poll_questions" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(Text, nullable=False) - - poll = relationship("Poll", back_populates="questions") - options = relationship( - "PollOption", - back_populates="question", - cascade="all, delete-orphan", - order_by="PollOption.order", - ) - - -class PollOption(Base): - __tablename__ = "poll_options" - - id = Column(Integer, primary_key=True, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(String(255), nullable=False) - - question = relationship("PollQuestion", back_populates="options") - - -class PollRun(Base): - __tablename__ = "poll_runs" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - target_type = Column(String(100), nullable=False) - status = Column(String(50), default="scheduled", nullable=False) - total_count = Column(Integer, default=0) - sent_count = Column(Integer, default=0) - failed_count = Column(Integer, default=0) - completed_count = Column(Integer, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="runs") - creator = relationship("User", backref="created_poll_runs") - - -class PollResponse(Base): - __tablename__ = "poll_responses" - __table_args__ = ( - UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True) - message_id = Column(Integer, nullable=True) - chat_id = Column(BigInteger, nullable=True) - is_completed = Column(Boolean, default=False, nullable=False) - reward_given = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="responses") - run = relationship("PollRun", backref="responses") - user = relationship("User", backref="poll_responses") - current_question = relationship("PollQuestion") - answers = relationship( - "PollAnswer", - back_populates="response", - cascade="all, delete-orphan", - ) - - -class PollAnswer(Base): - __tablename__ = "poll_answers" - - id = Column(Integer, primary_key=True, index=True) - response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - response = relationship("PollResponse", back_populates="answers") - question = relationship("PollQuestion") - option = relationship("PollOption") - class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py deleted file mode 100644 index d7130a54..00000000 --- a/app/handlers/admin/polls.py +++ /dev/null @@ -1,1259 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime -from decimal import Decimal, InvalidOperation -from typing import List - -from aiogram import Bot, Dispatcher, F, types -from aiogram.fsm.context import FSMContext -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.handlers.admin.messages import get_target_name, get_target_users -from app.localization.texts import get_texts -from app.states import AdminStates -from app.utils.decorators import admin_required, error_handler - -logger = logging.getLogger(__name__) - - -def _format_question_summary(index: int, question: PollQuestion) -> str: - escaped_question = html.escape(question.text) - lines = [f"{index}. {escaped_question}"] - for opt_index, option in enumerate(sorted(question.options, key=lambda o: o.order), start=1): - lines.append(f" {opt_index}) {html.escape(option.text)}") - return "\n".join(lines) - - -async def _get_poll(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options( - selectinload(Poll.questions).selectinload(PollQuestion.options), - selectinload(Poll.runs), - ) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_state_questions(data: dict) -> List[dict]: - return list(data.get("poll_questions", [])) - - -def _ensure_questions_present(questions: List[dict]) -> None: - if not questions: - raise ValueError("poll_without_questions") - - -@admin_required -@error_handler -async def show_polls_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - stmt = ( - select(Poll) - .options(selectinload(Poll.questions)) - .order_by(Poll.created_at.desc()) - ) - result = await db.execute(stmt) - polls = result.unique().scalars().all() - - text = ( - texts.t("ADMIN_POLLS_TITLE", "📋 Опросы") - + "\n\n" - + texts.t( - "ADMIN_POLLS_DESCRIPTION", - "Создавайте опросы и отправляйте их пользователям по категориям рассылок.", - ) - ) - - keyboard: list[list[types.InlineKeyboardButton]] = [] - for poll in polls: - question_count = len(poll.questions) - reward_label = ( - texts.t("ADMIN_POLLS_REWARD_ENABLED", "🎁 награда есть") - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_DISABLED", "без награды") - ) - button_text = f"📋 {poll.title} ({question_count}) — {reward_label}" - keyboard.append( - [ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_poll_{poll.id}", - ) - ] - ) - - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), - callback_data="admin_poll_create", - ) - ] - ) - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_communications", - ) - ] - ) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_poll_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_title) - await state.update_data( - poll_questions=[], - reward_enabled=False, - reward_amount_kopeks=0, - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_TITLE", - "🆕 Создание опроса\n\nВведите заголовок опроса.", - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_poll_title( - message: types.Message, - db_user: User, - state: FSMContext, -): - title = (message.text or "").strip() - texts = get_texts(db_user.language) - - if not title: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_TITLE_RETRY", "❗️ Укажите непустой заголовок."), - ) - return - - await state.update_data(poll_title=title) - await state.set_state(AdminStates.creating_poll_description) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_DESCRIPTION", - "✍️ Введите описание опроса. HTML-разметка поддерживается.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_description( - message: types.Message, - db_user: User, - state: FSMContext, -): - description = message.html_text or message.text or "" - description = description.strip() - texts = get_texts(db_user.language) - - if not description: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_DESCRIPTION_RETRY", "❗️ Описание не может быть пустым."), - ) - return - - await state.update_data(poll_description=description) - await state.set_state(AdminStates.creating_poll_question_text) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION", - "❓ Отправьте текст первого вопроса опроса.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_text( - message: types.Message, - db_user: User, - state: FSMContext, -): - question_text = (message.html_text or message.text or "").strip() - texts = get_texts(db_user.language) - - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_RETRY", - "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - ) - ) - return - - await state.update_data(current_question_text=question_text) - await state.set_state(AdminStates.creating_poll_question_options) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_OPTIONS", - "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_options( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_options = (message.text or "").splitlines() - options = [opt.strip() for opt in raw_options if opt.strip()] - - if len(options) < 2: - await message.answer( - texts.t( - "ADMIN_POLLS_NEED_MORE_OPTIONS", - "❗️ Укажите минимум два варианта ответа.", - ) - ) - return - - if len(options) > 10: - await message.answer( - texts.t( - "ADMIN_POLLS_TOO_MANY_OPTIONS", - "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - ) - ) - return - - data = await state.get_data() - question_text = data.get("current_question_text") - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_NOT_FOUND", - "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - ) - ) - await state.set_state(AdminStates.creating_poll_question_text) - return - - questions = _get_state_questions(data) - questions.append({"text": question_text, "options": options}) - await state.update_data( - poll_questions=questions, - current_question_text=None, - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_QUESTION", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIGURE_REWARD", "🎁 Настроить награду"), - callback_data="admin_poll_reward_menu", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - await state.set_state(None) - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_ADDED", - "✅ Вопрос добавлен. Выберите действие:", - ), - reply_markup=keyboard, - ) - - -@admin_required -@error_handler -async def add_another_question( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_question_text) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_NEXT", - "❓ Отправьте текст следующего вопроса.", - ) - ) - await callback.answer() - - -def _build_reward_menu(texts, data: dict) -> tuple[str, types.InlineKeyboardMarkup]: - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - questions = _get_state_questions(data) - - questions_summary = "\n".join( - f"{idx}. {html.escape(q['text'])}" for idx, q in enumerate(questions, start=1) - ) or texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —") - - reward_text = ( - texts.t("ADMIN_POLLS_REWARD_ON", "Включена") - if reward_enabled and reward_amount > 0 - else texts.t("ADMIN_POLLS_REWARD_OFF", "Отключена") - ) - reward_amount_label = texts.format_price(reward_amount) - - text = ( - texts.t("ADMIN_POLLS_REWARD_TITLE", "🎁 Награда за участие") - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_STATUS", "Статус: {status}" ).format(status=reward_text) - + "\n" - + texts.t( - "ADMIN_POLLS_REWARD_AMOUNT", - "Сумма: {amount}", - ).format(amount=reward_amount_label) - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_QUESTIONS", "Всего вопросов: {count}").format(count=len(questions)) - + "\n" - + questions_summary - ) - - toggle_text = ( - texts.t("ADMIN_POLLS_REWARD_DISABLE", "🚫 Отключить награду") - if reward_enabled - else texts.t("ADMIN_POLLS_REWARD_ENABLE", "🔔 Включить награду") - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_poll_toggle_reward", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_REWARD_SET_AMOUNT", "💰 Изменить сумму"), - callback_data="admin_poll_reward_amount", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SAVE", "✅ Сохранить опрос"), - callback_data="admin_poll_save", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_MORE", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - return text, keyboard - - -@admin_required -@error_handler -async def show_reward_menu( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - texts = get_texts(db_user.language) - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед настройкой награды.", - ), - show_alert=True, - ) - return - - text, keyboard = _build_reward_menu(texts, data) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def toggle_reward( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - reward_enabled = not reward_enabled - if reward_enabled and reward_amount <= 0: - reward_amount = 1000 - - await state.update_data( - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_amount, - ) - - texts = get_texts(db_user.language) - text, keyboard = _build_reward_menu(texts, await state.get_data()) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def request_reward_amount( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_reward_amount) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT", - "💰 Введите сумму награды в рублях (можно с копейками).", - ) - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_reward_amount( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_value = (message.text or "").replace(",", ".").strip() - - try: - value_decimal = Decimal(raw_value) - except (InvalidOperation, ValueError): - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_INVALID", - "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - ) - ) - return - - if value_decimal < 0: - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE", - "❗️ Сумма не может быть отрицательной.", - ) - ) - return - - amount_kopeks = int((value_decimal * 100).to_integral_value()) - await state.update_data( - reward_amount_kopeks=amount_kopeks, - reward_enabled=amount_kopeks > 0, - ) - await state.set_state(None) - - data = await state.get_data() - text, keyboard = _build_reward_menu(texts, data) - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") - - -@admin_required -@error_handler -async def save_poll( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(db_user.language) - - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед сохранением.", - ), - show_alert=True, - ) - return - - title = data.get("poll_title") - description = data.get("poll_description") - questions = _get_state_questions(data) - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - if not title or not description: - await callback.answer( - texts.t( - "ADMIN_POLLS_MISSING_DATA", - "Заполните заголовок и описание перед сохранением.", - ), - show_alert=True, - ) - return - - poll = Poll( - title=title, - description=description, - reward_enabled=reward_enabled and reward_amount > 0, - reward_amount_kopeks=reward_amount if reward_amount > 0 else 0, - created_by=db_user.id, - created_at=datetime.utcnow(), - ) - - try: - db.add(poll) - await db.flush() - - for q_index, question_data in enumerate(questions, start=1): - question = PollQuestion( - poll_id=poll.id, - text=question_data["text"], - order=q_index, - ) - db.add(question) - await db.flush() - - for opt_index, option_text in enumerate(question_data["options"], start=1): - option = PollOption( - question_id=question.id, - text=option_text, - order=opt_index, - ) - db.add(option) - - await db.commit() - await state.clear() - - poll = await _get_poll(db, poll.id) - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - summary_text = ( - texts.t("ADMIN_POLLS_CREATED", "✅ Опрос сохранён!") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions)) - + "\n" - + reward_info - + "\n\n" - + "\n".join(question_lines) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_OPEN", "📋 К опросу"), - callback_data=f"admin_poll_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - summary_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - except Exception as exc: # pragma: no cover - defensive logging - await db.rollback() - logger.exception("Failed to create poll: %s", exc) - await callback.answer( - texts.t( - "ADMIN_POLLS_SAVE_ERROR", - "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - ), - show_alert=True, - ) - - -@admin_required -@error_handler -async def show_poll_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден или был удалён."), - show_alert=True, - ) - return - - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - - runs_total = sum(run.sent_count for run in poll.runs) - completions = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - description_preview = html.escape(poll.description) - - text = ( - f"📋 {html.escape(poll.title)}\n\n" - + texts.t("ADMIN_POLLS_DESCRIPTION_LABEL", "Описание:") - + f"\n{description_preview}\n\n" - + texts.t( - "ADMIN_POLLS_STATS_SENT", - "Отправлено сообщений: {count}", - ).format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED", - "Завершили опрос: {count}", - ).format(count=completions) - + "\n" - + reward_info - + "\n\n" - + texts.t("ADMIN_POLLS_QUESTIONS_LIST", "Вопросы:") - + "\n" - + ("\n".join(question_lines) if question_lines else texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND", "🚀 Отправить"), - callback_data=f"admin_poll_send_{poll.id}", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_target_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_QUESTIONS", - "Сначала добавьте вопросы к опросу, чтобы отправлять его пользователям.", - ), - show_alert=True, - ) - return - - from app.keyboards.admin import get_poll_target_keyboard - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_SELECT_TARGET", - "🎯 Выберите категорию пользователей для отправки опроса.", - ), - reply_markup=get_poll_target_keyboard(poll.id, db_user.language), - ) - await callback.answer() - - -@admin_required -@error_handler -async def preview_poll_target( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, _, poll_id_str, target = callback.data.split("_", 3) - poll_id = int(poll_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - target_name = get_target_name(target) - - confirm_keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIRM_SEND", "✅ Отправить"), - callback_data=f"admin_poll_send_confirm_{poll_id}_{target}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_send_{poll_id}", - ) - ], - ] - ) - - text = ( - texts.t("ADMIN_POLLS_CONFIRMATION_TITLE", "📨 Подтверждение отправки") - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_BODY", - "Категория: {category}\nПользователей: {count}", - ).format(category=target_name, count=len(users)) - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_HINT", - "После отправки пользователи получат приглашение пройти опрос.", - ) - ) - - await callback.message.edit_text( - text, - reply_markup=confirm_keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -async def _send_poll_invitation( - bot: Bot, - poll: Poll, - run: PollRun, - users: list, -) -> tuple[int, int]: - sent_count = 0 - failed_count = 0 - - invite_text = ( - f"📋 {html.escape(poll.title)}\n\n" - f"{poll.description}\n\n" - "📝 Нажмите кнопку ниже, чтобы пройти опрос." - ) - - for index, user in enumerate(users, start=1): - try: - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="📝 Пройти опрос", - callback_data=f"poll_start_{poll.id}_{run.id}", - ) - ] - ] - ) - await bot.send_message( - chat_id=user.telegram_id, - text=invite_text, - reply_markup=keyboard, - ) - sent_count += 1 - except Exception as exc: # pragma: no cover - defensive logging - failed_count += 1 - logger.warning( - "Failed to send poll %s to user %s: %s", - poll.id, - getattr(user, "telegram_id", "unknown"), - exc, - ) - if index % 25 == 0: - await asyncio.sleep(0.5) - - return sent_count, failed_count - - -@admin_required -@error_handler -async def confirm_poll_sending( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - parts = callback.data.split("_") - if len(parts) < 6: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - try: - poll_id = int(parts[4]) - except ValueError: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - target = "_".join(parts[5:]) - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - if not users: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_USERS", - "Подходящих пользователей не найдено для выбранной категории.", - ), - show_alert=True, - ) - return - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_SENDING", "📨 Отправляем опрос..."), - ) - - run = PollRun( - poll_id=poll.id, - target_type=target, - status="in_progress", - total_count=len(users), - created_by=db_user.id, - created_at=datetime.utcnow(), - started_at=datetime.utcnow(), - ) - db.add(run) - await db.flush() - - sent_count, failed_count = await _send_poll_invitation(callback.bot, poll, run, users) - - run.sent_count = sent_count - run.failed_count = failed_count - run.status = "completed" - run.completed_at = datetime.utcnow() - - await db.commit() - - result_text = ( - texts.t("ADMIN_POLLS_SENT", "✅ Отправка завершена!") - + "\n\n" - + texts.t("ADMIN_POLLS_SENT_SUCCESS", "Успешно отправлено: {count}").format(count=sent_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_FAILED", "Ошибок доставки: {count}").format(count=failed_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_TOTAL", "Всего пользователей: {count}").format(count=len(users)) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - total_responses = await db.scalar( - select(func.count(PollResponse.id)).where(PollResponse.poll_id == poll.id) - ) or 0 - completed_responses = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - reward_sum = await db.scalar( - select(func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0)).where( - PollResponse.poll_id == poll.id, - PollResponse.reward_given.is_(True), - ) - ) or 0 - - runs_total = await db.scalar( - select(func.coalesce(func.sum(PollRun.sent_count), 0)).where(PollRun.poll_id == poll.id) - ) or 0 - - answers_stmt = ( - select(PollAnswer.question_id, PollAnswer.option_id, func.count(PollAnswer.id)) - .join(PollResponse, PollResponse.id == PollAnswer.response_id) - .where(PollResponse.poll_id == poll.id) - .group_by(PollAnswer.question_id, PollAnswer.option_id) - ) - answers_result = await db.execute(answers_stmt) - answer_counts = { - (question_id, option_id): count - for question_id, option_id, count in answers_result.all() - } - - question_lines = [] - for question in sorted(poll.questions, key=lambda q: q.order): - total_answers_for_question = sum( - answer_counts.get((question.id, option.id), 0) - for option in question.options - ) or 0 - question_lines.append(f"{html.escape(question.text)}") - for option in sorted(question.options, key=lambda o: o.order): - option_count = answer_counts.get((question.id, option.id), 0) - percent = ( - round(option_count / total_answers_for_question * 100, 1) - if total_answers_for_question - else 0 - ) - question_lines.append( - texts.t( - "ADMIN_POLLS_STATS_OPTION", - "• {text} — {count} ({percent}%)", - ).format( - text=html.escape(option.text), - count=option_count, - percent=percent, - ) - ) - question_lines.append("") - - text = ( - texts.t("ADMIN_POLLS_STATS_TITLE", "📊 Статистика опроса") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_STATS_SENT", "Сообщений отправлено: {count}").format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_RESPONDED", - "Ответов получено: {count}", - ).format(count=total_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED_LABEL", - "Прошли до конца: {count}", - ).format(count=completed_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_REWARD_TOTAL", - "Выдано наград: {amount}", - ).format(amount=texts.format_price(reward_sum)) - + "\n\n" - + ("\n".join(question_lines).strip() or texts.t("ADMIN_POLLS_STATS_NO_DATA", "Ответов пока нет.")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def ask_delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE_CONFIRM", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_confirm_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_DELETE_PROMPT", - "❓ Удалить опрос? Это действие нельзя отменить.", - ), - reply_markup=keyboard, - ) - await callback.answer() - - -@admin_required -@error_handler -async def delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await db.get(Poll, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос уже удалён."), - show_alert=True, - ) - return - - await db.delete(poll) - await db.commit() - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_BACK_TO_LIST", "⬅️ К списку опросов"), - callback_data="admin_polls", - ) - ] - ] - ), - ) - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(show_polls_menu, F.data == "admin_polls") - dp.callback_query.register(start_poll_creation, F.data == "admin_poll_create") - dp.message.register(process_poll_title, AdminStates.creating_poll_title) - dp.message.register(process_poll_description, AdminStates.creating_poll_description) - dp.message.register(process_poll_question_text, AdminStates.creating_poll_question_text) - dp.message.register(process_poll_question_options, AdminStates.creating_poll_question_options) - dp.message.register(process_reward_amount, AdminStates.creating_poll_reward_amount) - - dp.callback_query.register(add_another_question, F.data == "admin_poll_add_question") - dp.callback_query.register(show_reward_menu, F.data == "admin_poll_reward_menu") - dp.callback_query.register(toggle_reward, F.data == "admin_poll_toggle_reward") - dp.callback_query.register(request_reward_amount, F.data == "admin_poll_reward_amount") - dp.callback_query.register(save_poll, F.data == "admin_poll_save") - - dp.callback_query.register( - show_poll_details, - F.data.regexp(r"^admin_poll_(?!send_|stats_|delete_|create).+"), - ) - dp.callback_query.register( - show_poll_target_selection, - F.data.regexp(r"^admin_poll_send_\\d+$"), - ) - dp.callback_query.register(preview_poll_target, F.data.startswith("poll_target_")) - dp.callback_query.register( - confirm_poll_sending, - F.data.regexp(r"^admin_poll_send_confirm_\\d+_.+"), - ) - dp.callback_query.register( - show_poll_stats, - F.data.regexp(r"^admin_poll_stats_\\d+$"), - ) - dp.callback_query.register( - ask_delete_poll, - F.data.regexp(r"^admin_poll_delete_\\d+$"), - ) - dp.callback_query.register( - delete_poll, - F.data.regexp(r"^admin_poll_delete_confirm_\\d+$"), - ) diff --git a/app/handlers/polls.py b/app/handlers/polls.py deleted file mode 100644 index 491519ca..00000000 --- a/app/handlers/polls.py +++ /dev/null @@ -1,322 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime - -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.crud.user import add_user_balance -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.localization.texts import get_texts -from app.utils.decorators import error_handler - -logger = logging.getLogger(__name__) - - -async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options(selectinload(Poll.questions).selectinload(PollQuestion.options)) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -async def _get_response( - db: AsyncSession, - poll_id: int, - user_id: int, -) -> PollResponse | None: - stmt = ( - select(PollResponse) - .options(selectinload(PollResponse.answers)) - .where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_next_question( - poll: Poll, - response: PollResponse, -) -> PollQuestion | None: - questions = sorted(poll.questions, key=lambda q: q.order) - answered_ids = {answer.question_id for answer in response.answers} - for question in questions: - if question.id not in answered_ids: - return question - return None - - -async def _delete_message_after_delay(bot: Bot, chat_id: int, message_id: int) -> None: - await asyncio.sleep(10) - try: - await bot.delete_message(chat_id, message_id) - except TelegramBadRequest: - pass - except Exception as exc: # pragma: no cover - defensive logging - logger.warning("Failed to delete poll message %s: %s", message_id, exc) - - -def _build_question_text( - poll: Poll, - question: PollQuestion, - question_index: int, - total_questions: int, - include_description: bool, -) -> str: - header = f"📋 {html.escape(poll.title)}\n\n" - body = "" - if include_description: - body += f"{poll.description}\n\n" - body += ( - f"❓ {question_index}/{total_questions}\n" - f"{html.escape(question.text)}" - ) - return header + body - - -def _build_question_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: - buttons = [] - for option in sorted(question.options, key=lambda o: o.order): - buttons.append( - [ - types.InlineKeyboardButton( - text=option.text, - callback_data=f"poll_answer_{response_id}_{question.id}_{option.id}", - ) - ] - ) - return types.InlineKeyboardMarkup(inline_keyboard=buttons) - - -@error_handler -async def start_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, poll_id_str, run_id_str = callback.data.split("_", 2) - poll_id = int(poll_id_str) - run_id = int(run_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll_with_questions(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), - show_alert=True, - ) - return - - response = await _get_response(db, poll.id, db_user.id) - if response and response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - if not response: - response = PollResponse( - poll_id=poll.id, - run_id=run_id, - user_id=db_user.id, - message_id=callback.message.message_id, - chat_id=callback.message.chat.id, - created_at=datetime.utcnow(), - ) - db.add(response) - await db.flush() - else: - response.message_id = callback.message.message_id - response.chat_id = callback.message.chat.id - await db.flush() - - next_question = _get_next_question(poll, response) - if not next_question: - await callback.answer( - texts.t("POLL_NO_QUESTIONS", "Вопросы опроса не найдены."), - show_alert=True, - ) - return - - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = len(response.answers) == 0 - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - - -@error_handler -async def answer_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, response_id_str, question_id_str, option_id_str = callback.data.split("_", 3) - response_id = int(response_id_str) - question_id = int(question_id_str) - option_id = int(option_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - response = await db.get(PollResponse, response_id) - if not response or response.user_id != db_user.id: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - if response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - poll = await _get_poll_with_questions(db, response.poll_id) - if not poll: - await callback.answer(texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), show_alert=True) - return - - question = next((q for q in poll.questions if q.id == question_id), None) - if not question: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - option = next((opt for opt in question.options if opt.id == option_id), None) - if not option: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - existing_answer = next((ans for ans in response.answers if ans.question_id == question_id), None) - if existing_answer: - await callback.answer(texts.t("POLL_OPTION_ALREADY_CHOSEN", "Ответ уже выбран.")) - return - - answer = PollAnswer( - response_id=response.id, - question_id=question.id, - option_id=option.id, - ) - db.add(answer) - await db.flush() - - response.answers.append(answer) - - next_question = _get_next_question(poll, response) - - if next_question: - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = False - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - return - - # Completed - response.current_question_id = None - response.is_completed = True - response.completed_at = datetime.utcnow() - - reward_text = "" - if poll.reward_enabled and poll.reward_amount_kopeks > 0 and not response.reward_given: - success = await add_user_balance( - db, - db_user, - poll.reward_amount_kopeks, - description=texts.t( - "POLL_REWARD_DESCRIPTION", - "Награда за участие в опросе '{title}'", - ).format(title=poll.title), - ) - if success: - response.reward_given = True - response.reward_amount_kopeks = poll.reward_amount_kopeks - reward_text = texts.t( - "POLL_REWARD_RECEIVED", - "\n\n🎁 На баланс зачислено: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - else: - logger.warning("Failed to add reward for poll %s to user %s", poll.id, db_user.telegram_id) - - if response.run_id: - run = await db.get(PollRun, response.run_id) - if run: - run.completed_count = (run.completed_count or 0) + 1 - - await db.commit() - - thank_you_text = ( - texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!") - + reward_text - ) - - try: - await callback.message.edit_text(thank_you_text) - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(thank_you_text) - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - - if response.chat_id and response.message_id: - asyncio.create_task( - _delete_message_after_delay(callback.bot, response.chat_id, response.message_id) - ) - - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(start_poll, F.data.startswith("poll_start_")) - dp.callback_query.register(answer_poll, F.data.startswith("poll_answer_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 480dd283..75e72bc0 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -96,17 +96,11 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"), - callback_data="admin_polls" - ) - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), @@ -978,54 +972,6 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data=f"poll_target_{poll_id}_all" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data=f"poll_target_{poll_id}_active" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data=f"poll_target_{poll_id}_trial" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data=f"poll_target_{poll_id}_no" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data=f"poll_target_{poll_id}_expiring" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data=f"poll_target_{poll_id}_expired" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data=f"poll_target_{poll_id}_active_zero" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data=f"poll_target_{poll_id}_trial_zero" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")] - ]) - - def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b9206713..eee72fe3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Polls", - "ADMIN_POLLS_TITLE": "📋 Polls", - "ADMIN_POLLS_DESCRIPTION": "Create polls and send them to users by broadcast categories.", - "ADMIN_POLLS_REWARD_ENABLED": "reward enabled", - "ADMIN_POLLS_REWARD_DISABLED": "no reward", - "ADMIN_POLLS_CREATE": "➕ Create poll", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Create a poll\n\nSend the poll title.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Title cannot be empty.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Send the poll description. HTML markup is supported.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Description cannot be empty.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Send the text of the first question.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Question text cannot be empty. Please resend it.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Send answer options, one per line (min 2, max 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Provide at least two answer options.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Maximum 10 options. Please send the list again.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Could not find question text. Start again by creating a question.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Add another question", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Configure reward", - "ADMIN_POLLS_CANCEL": "❌ Cancel", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Question saved. Choose the next action:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Send the next question text.", - "ADMIN_POLLS_NO_QUESTIONS": "— no questions yet —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Reward settings", - "ADMIN_POLLS_REWARD_STATUS": "Status: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Amount: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Total questions: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Disable reward", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Enable reward", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Change amount", - "ADMIN_POLLS_SAVE": "✅ Save poll", - "ADMIN_POLLS_ADD_MORE": "➕ Add another question", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Add at least one question before configuring the reward.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Enter reward amount in RUB (decimals allowed).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Could not parse the number. Use format 10 or 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Amount cannot be negative.", - "ADMIN_POLLS_MISSING_DATA": "Fill in title and description before saving.", - "ADMIN_POLLS_REWARD_ON": "Enabled", - "ADMIN_POLLS_REWARD_OFF": "Disabled", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Reward: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Reward: not provided", - "ADMIN_POLLS_CREATED": "✅ Poll saved!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", - "ADMIN_POLLS_OPEN": "📋 Open poll", - "ADMIN_POLLS_SAVE_ERROR": "❌ Failed to save poll. Please try again later.", - "ADMIN_POLLS_NOT_FOUND": "Poll not found or already removed.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Description:", - "ADMIN_POLLS_STATS_SENT": "Messages sent: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Questions:", - "ADMIN_POLLS_SEND": "🚀 Send", - "ADMIN_POLLS_STATS_BUTTON": "📊 Stats", - "ADMIN_POLLS_DELETE": "🗑️ Delete", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Choose a user segment for this poll.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Send", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Send confirmation", - "ADMIN_POLLS_CONFIRMATION_BODY": "Segment: {category}\nUsers: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "Users will receive an invite to complete the poll.", - "ADMIN_POLLS_NO_USERS": "No users matched the selected category.", - "ADMIN_POLLS_SENDING": "📨 Sending the poll...", - "ADMIN_POLLS_SENT": "✅ Sending completed!", - "ADMIN_POLLS_SENT_SUCCESS": "Successfully sent: {count}", - "ADMIN_POLLS_SENT_FAILED": "Failed deliveries: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Total recipients: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics", - "ADMIN_POLLS_STATS_RESPONDED": "Responses received: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "No answers yet.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.", - "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list", - "POLL_NOT_AVAILABLE": "Poll is not available.", - "POLL_ALREADY_PASSED": "You have already completed this poll.", - "POLL_NO_QUESTIONS": "No poll questions found.", - "POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.", - "POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}", - "POLL_COMPLETED": "🙏 Thanks for completing the poll!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 53ef53cd..ca6da8ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы", - "ADMIN_POLLS_TITLE": "📋 Опросы", - "ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.", - "ADMIN_POLLS_REWARD_ENABLED": "есть награда", - "ADMIN_POLLS_REWARD_DISABLED": "без награды", - "ADMIN_POLLS_CREATE": "➕ Создать опрос", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Создание опроса\n\nВведите заголовок опроса.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Укажите непустой заголовок.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Введите описание опроса. HTML-разметка поддерживается.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Описание не может быть пустым.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Отправьте текст первого вопроса опроса.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Укажите минимум два варианта ответа.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Настроить награду", - "ADMIN_POLLS_CANCEL": "❌ Отмена", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Вопрос добавлен. Выберите действие:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Отправьте текст следующего вопроса.", - "ADMIN_POLLS_NO_QUESTIONS": "— вопросы не добавлены —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Награда за участие", - "ADMIN_POLLS_REWARD_STATUS": "Статус: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Сумма: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Всего вопросов: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Отключить награду", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Включить награду", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Изменить сумму", - "ADMIN_POLLS_SAVE": "✅ Сохранить опрос", - "ADMIN_POLLS_ADD_MORE": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Добавьте хотя бы один вопрос перед настройкой награды.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Введите сумму награды в рублях (можно с копейками).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Сумма не может быть отрицательной.", - "ADMIN_POLLS_MISSING_DATA": "Заполните заголовок и описание перед сохранением.", - "ADMIN_POLLS_REWARD_ON": "Включена", - "ADMIN_POLLS_REWARD_OFF": "Отключена", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Награда: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Награда: не выдается", - "ADMIN_POLLS_CREATED": "✅ Опрос сохранён!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", - "ADMIN_POLLS_OPEN": "📋 К опросу", - "ADMIN_POLLS_SAVE_ERROR": "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - "ADMIN_POLLS_NOT_FOUND": "Опрос не найден или был удалён.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Описание:", - "ADMIN_POLLS_STATS_SENT": "Отправлено сообщений: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Вопросы:", - "ADMIN_POLLS_SEND": "🚀 Отправить", - "ADMIN_POLLS_STATS_BUTTON": "📊 Статистика", - "ADMIN_POLLS_DELETE": "🗑️ Удалить", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Выберите категорию пользователей для отправки опроса.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Отправить", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Подтверждение отправки", - "ADMIN_POLLS_CONFIRMATION_BODY": "Категория: {category}\nПользователей: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.", - "ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.", - "ADMIN_POLLS_SENDING": "📨 Отправляем опрос...", - "ADMIN_POLLS_SENT": "✅ Отправка завершена!", - "ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: {count}", - "ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса", - "ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.", - "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов", - "POLL_NOT_AVAILABLE": "Опрос недоступен.", - "POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.", - "POLL_NO_QUESTIONS": "Вопросы опроса не найдены.", - "POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.", - "POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}", - "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" } diff --git a/app/states.py b/app/states.py index b88ce142..549e314f 100644 --- a/app/states.py +++ b/app/states.py @@ -70,12 +70,6 @@ class AdminStates(StatesGroup): waiting_for_broadcast_media = State() confirming_broadcast = State() - creating_poll_title = State() - creating_poll_description = State() - creating_poll_question_text = State() - creating_poll_question_options = State() - creating_poll_reward_amount = State() - creating_promo_group_name = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() diff --git a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py deleted file mode 100644 index 31da4428..00000000 --- a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py +++ /dev/null @@ -1,238 +0,0 @@ -"""add polls tables""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.engine.reflection import Inspector - -revision: str = "a3f94c8b91dd" -down_revision: Union[str, None] = "8fd1e338eb45" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - -POLL_TABLE = "polls" -POLL_QUESTIONS_TABLE = "poll_questions" -POLL_OPTIONS_TABLE = "poll_options" -POLL_RUNS_TABLE = "poll_runs" -POLL_RESPONSES_TABLE = "poll_responses" -POLL_ANSWERS_TABLE = "poll_answers" - - -def _table_exists(inspector: Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_TABLE): - op.create_table( - POLL_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("title", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=False), - sa.Column( - "reward_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.create_table( - POLL_QUESTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.Text(), nullable=False), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_OPTIONS_TABLE): - op.create_table( - POLL_OPTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.String(length=255), nullable=False), - sa.ForeignKeyConstraint( - ["question_id"], - [f"{POLL_QUESTIONS_TABLE}.id"], - ondelete="CASCADE", - ), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RUNS_TABLE): - op.create_table( - POLL_RUNS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("target_type", sa.String(length=100), nullable=False), - sa.Column( - "status", - sa.String(length=50), - nullable=False, - server_default="scheduled", - ), - sa.Column( - "total_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "sent_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "failed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "completed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RESPONSES_TABLE): - op.create_table( - POLL_RESPONSES_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("run_id", sa.Integer(), nullable=True), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("current_question_id", sa.Integer(), nullable=True), - sa.Column("message_id", sa.Integer(), nullable=True), - sa.Column("chat_id", sa.BigInteger(), nullable=True), - sa.Column( - "is_completed", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_given", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"), - sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_ANSWERS_TABLE): - op.create_table( - POLL_ANSWERS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("response_id", sa.Integer(), nullable=False), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column("option_id", sa.Integer(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_ANSWERS_TABLE): - op.drop_table(POLL_ANSWERS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RESPONSES_TABLE): - op.drop_table(POLL_RESPONSES_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RUNS_TABLE): - op.drop_table(POLL_RUNS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_OPTIONS_TABLE): - op.drop_table(POLL_OPTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.drop_table(POLL_QUESTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_TABLE): - op.drop_table(POLL_TABLE) From e592b3e5c4f7ddda0ade84cd9f61730bc2048e0e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:03:41 +0300 Subject: [PATCH 08/31] Revert "Revert "Add poll management and delivery system"" --- app/bot.py | 16 +- app/database/crud/poll.py | 265 ++++++ app/database/crud/user.py | 21 +- app/database/models.py | 116 ++- app/handlers/admin/polls.py | 825 ++++++++++++++++++ app/handlers/polls.py | 197 +++++ app/keyboards/admin.py | 6 + app/localization/locales/en.json | 47 +- app/localization/locales/ru.json | 47 +- app/services/poll_service.py | 179 ++++ .../versions/9f0f2d5a1c7b_add_polls_tables.py | 155 ++++ 11 files changed, 1856 insertions(+), 18 deletions(-) create mode 100644 app/database/crud/poll.py create mode 100644 app/handlers/admin/polls.py create mode 100644 app/handlers/polls.py create mode 100644 app/services/poll_service.py create mode 100644 migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py diff --git a/app/bot.py b/app/bot.py index f21b7534..826d8d6e 100644 --- a/app/bot.py +++ b/app/bot.py @@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service from app.utils.cache import cache from app.handlers import ( - start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets + start, + menu, + subscription, + balance, + promocode, + referral, + support, + server_status, + common, + tickets, ) +from app.handlers import polls as user_polls from app.handlers import simple_subscription from app.handlers.admin import ( main as admin_main, @@ -31,6 +40,7 @@ from app.handlers.admin import ( rules as admin_rules, remnawave as admin_remnawave, statistics as admin_statistics, + polls as admin_polls, servers as admin_servers, maintenance as admin_maintenance, promo_groups as admin_promo_groups, @@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_polls.register_handlers(dp) admin_promo_groups.register_handlers(dp) admin_campaigns.register_handlers(dp) admin_promo_offers.register_handlers(dp) @@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_faq.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) + user_polls.register_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") logger.info("⚡ Зарегистрированы обработчики простой покупки") diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py new file mode 100644 index 00000000..5b3b35ad --- /dev/null +++ b/app/database/crud/poll.py @@ -0,0 +1,265 @@ +import logging +from typing import Iterable, Sequence + +from sqlalchemy import and_, delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, +) + +logger = logging.getLogger(__name__) + + +async def create_poll( + db: AsyncSession, + *, + title: str, + description: str | None, + reward_enabled: bool, + reward_amount_kopeks: int, + created_by: int | None, + questions: Sequence[dict[str, Iterable[str]]], +) -> Poll: + poll = Poll( + title=title, + description=description, + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0, + created_by=created_by, + ) + db.add(poll) + await db.flush() + + for order, question_data in enumerate(questions, start=1): + question_text = question_data.get("text", "").strip() + if not question_text: + continue + + question = PollQuestion( + poll_id=poll.id, + text=question_text, + order=order, + ) + db.add(question) + await db.flush() + + for option_order, option_text in enumerate(question_data.get("options", []), start=1): + option_text = option_text.strip() + if not option_text: + continue + option = PollOption( + question_id=question.id, + text=option_text, + order=option_order, + ) + db.add(option) + + await db.commit() + await db.refresh( + poll, + attribute_names=["questions"], + ) + return poll + + +async def list_polls(db: AsyncSession) -> list[Poll]: + result = await db.execute( + select(Poll) + .options( + selectinload(Poll.questions).options(selectinload(PollQuestion.options)) + ) + .order_by(Poll.created_at.desc()) + ) + return result.scalars().all() + + +async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None: + result = await db.execute( + select(Poll) + .options( + selectinload(Poll.questions).options(selectinload(PollQuestion.options)), + selectinload(Poll.responses), + ) + .where(Poll.id == poll_id) + ) + return result.scalar_one_or_none() + + +async def delete_poll(db: AsyncSession, poll_id: int) -> bool: + poll = await db.get(Poll, poll_id) + if not poll: + return False + + await db.delete(poll) + await db.commit() + logger.info("🗑️ Удалён опрос %s", poll_id) + return True + + +async def create_poll_response( + db: AsyncSession, + poll_id: int, + user_id: int, +) -> PollResponse: + result = await db.execute( + select(PollResponse) + .where( + and_( + PollResponse.poll_id == poll_id, + PollResponse.user_id == user_id, + ) + ) + ) + response = result.scalar_one_or_none() + if response: + return response + + response = PollResponse( + poll_id=poll_id, + user_id=user_id, + ) + db.add(response) + await db.commit() + await db.refresh(response) + return response + + +async def get_poll_response_by_id( + db: AsyncSession, + response_id: int, +) -> PollResponse | None: + result = await db.execute( + select(PollResponse) + .options( + selectinload(PollResponse.poll) + .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))), + selectinload(PollResponse.answers), + selectinload(PollResponse.user), + ) + .where(PollResponse.id == response_id) + ) + return result.scalar_one_or_none() + + +async def record_poll_answer( + db: AsyncSession, + *, + response_id: int, + question_id: int, + option_id: int, +) -> PollAnswer: + result = await db.execute( + select(PollAnswer) + .where( + and_( + PollAnswer.response_id == response_id, + PollAnswer.question_id == question_id, + ) + ) + ) + answer = result.scalar_one_or_none() + if answer: + answer.option_id = option_id + await db.commit() + await db.refresh(answer) + return answer + + answer = PollAnswer( + response_id=response_id, + question_id=question_id, + option_id=option_id, + ) + db.add(answer) + await db.commit() + await db.refresh(answer) + return answer + + +async def reset_poll_answers(db: AsyncSession, response_id: int) -> None: + await db.execute( + delete(PollAnswer).where(PollAnswer.response_id == response_id) + ) + await db.commit() + + +async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: + totals_result = await db.execute( + select( + func.count(PollResponse.id), + func.count(PollResponse.completed_at), + func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0), + ).where(PollResponse.poll_id == poll_id) + ) + total_responses, completed_responses, reward_sum = totals_result.one() + + option_counts_result = await db.execute( + select( + PollQuestion.id, + PollQuestion.text, + PollQuestion.order, + PollOption.id, + PollOption.text, + PollOption.order, + func.count(PollAnswer.id), + ) + .join(PollOption, PollOption.question_id == PollQuestion.id) + .outerjoin( + PollAnswer, + and_( + PollAnswer.question_id == PollQuestion.id, + PollAnswer.option_id == PollOption.id, + ), + ) + .where(PollQuestion.poll_id == poll_id) + .group_by( + PollQuestion.id, + PollQuestion.text, + PollQuestion.order, + PollOption.id, + PollOption.text, + PollOption.order, + ) + .order_by(PollQuestion.order.asc(), PollOption.order.asc()) + ) + + questions_map: dict[int, dict] = {} + for ( + question_id, + question_text, + question_order, + option_id, + option_text, + option_order, + answer_count, + ) in option_counts_result: + question_entry = questions_map.setdefault( + question_id, + { + "id": question_id, + "text": question_text, + "order": question_order, + "options": [], + }, + ) + question_entry["options"].append( + { + "id": option_id, + "text": option_text, + "count": answer_count, + } + ) + + questions = sorted(questions_map.values(), key=lambda item: item["order"]) + + return { + "total_responses": total_responses, + "completed_responses": completed_responses, + "reward_sum_kopeks": reward_sum, + "questions": questions, + } diff --git a/app/database/crud/user.py b/app/database/crud/user.py index e9dea8c2..67112086 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -219,7 +219,8 @@ async def add_user_balance( amount_kopeks: int, description: str = "Пополнение баланса", create_transaction: bool = True, - bot = None + transaction_type: TransactionType = TransactionType.DEPOSIT, + bot = None ) -> bool: try: old_balance = user.balance_kopeks @@ -228,12 +229,11 @@ async def add_user_balance( if create_transaction: from app.database.crud.transaction import create_transaction as create_trans - from app.database.models import TransactionType - + await create_trans( db=db, user_id=user.id, - type=TransactionType.DEPOSIT, + type=transaction_type, amount_kopeks=amount_kopeks, description=description ) @@ -253,9 +253,10 @@ async def add_user_balance( async def add_user_balance_by_id( db: AsyncSession, - telegram_id: int, + telegram_id: int, amount_kopeks: int, - description: str = "Пополнение баланса" + description: str = "Пополнение баланса", + transaction_type: TransactionType = TransactionType.DEPOSIT, ) -> bool: try: user = await get_user_by_telegram_id(db, telegram_id) @@ -263,7 +264,13 @@ async def add_user_balance_by_id( logger.error(f"Пользователь с telegram_id {telegram_id} не найден") return False - return await add_user_balance(db, user, amount_kopeks, description) + return await add_user_balance( + db, + user, + amount_kopeks, + description, + transaction_type=transaction_type, + ) except Exception as e: logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}") diff --git a/app/database/models.py b/app/database/models.py index d296aba9..b4e9bc80 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -58,11 +58,12 @@ class SubscriptionStatus(Enum): class TransactionType(Enum): - DEPOSIT = "deposit" - WITHDRAWAL = "withdrawal" - SUBSCRIPTION_PAYMENT = "subscription_payment" - REFUND = "refund" - REFERRAL_REWARD = "referral_reward" + DEPOSIT = "deposit" + WITHDRAWAL = "withdrawal" + SUBSCRIPTION_PAYMENT = "subscription_payment" + REFUND = "refund" + REFERRAL_REWARD = "referral_reward" + POLL_REWARD = "poll_reward" class PromoCodeType(Enum): @@ -530,6 +531,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + poll_responses = relationship("PollResponse", back_populates="user") @property def balance_rubles(self) -> float: @@ -1061,9 +1063,9 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) - target_type = Column(String(100), nullable=False) + target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) has_media = Column(Boolean, default=False) media_type = Column(String(20), nullable=True) @@ -1079,6 +1081,106 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") + +class Poll(Base): + __tablename__ = "polls" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + reward_enabled = Column(Boolean, nullable=False, default=False) + reward_amount_kopeks = Column(Integer, nullable=False, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False) + + creator = relationship("User", backref="created_polls", foreign_keys=[created_by]) + questions = relationship( + "PollQuestion", + back_populates="poll", + cascade="all, delete-orphan", + order_by="PollQuestion.order", + ) + responses = relationship( + "PollResponse", + back_populates="poll", + cascade="all, delete-orphan", + ) + + +class PollQuestion(Base): + __tablename__ = "poll_questions" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) + text = Column(Text, nullable=False) + order = Column(Integer, nullable=False, default=0) + + poll = relationship("Poll", back_populates="questions") + options = relationship( + "PollOption", + back_populates="question", + cascade="all, delete-orphan", + order_by="PollOption.order", + ) + answers = relationship("PollAnswer", back_populates="question") + + +class PollOption(Base): + __tablename__ = "poll_options" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) + text = Column(Text, nullable=False) + order = Column(Integer, nullable=False, default=0) + + question = relationship("PollQuestion", back_populates="options") + answers = relationship("PollAnswer", back_populates="option") + + +class PollResponse(Base): + __tablename__ = "poll_responses" + + id = Column(Integer, primary_key=True, index=True) + poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + sent_at = Column(DateTime, default=func.now(), nullable=False) + started_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + reward_given = Column(Boolean, nullable=False, default=False) + reward_amount_kopeks = Column(Integer, nullable=False, default=0) + + poll = relationship("Poll", back_populates="responses") + user = relationship("User", back_populates="poll_responses") + answers = relationship( + "PollAnswer", + back_populates="response", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + + +class PollAnswer(Base): + __tablename__ = "poll_answers" + + id = Column(Integer, primary_key=True, index=True) + response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) + option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True) + created_at = Column(DateTime, default=func.now(), nullable=False) + + response = relationship("PollResponse", back_populates="answers") + question = relationship("PollQuestion", back_populates="answers") + option = relationship("PollOption", back_populates="answers") + + __table_args__ = ( + UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), + ) + + class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py new file mode 100644 index 00000000..1220cae4 --- /dev/null +++ b/app/handlers/admin/polls.py @@ -0,0 +1,825 @@ +import html +import logging +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from typing import Optional + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.poll import ( + create_poll, + delete_poll, + get_poll_by_id, + get_poll_statistics, + list_polls, +) +from app.database.models import Poll, User +from app.handlers.admin.messages import ( + get_custom_users, + get_custom_users_count, + get_target_display_name, + get_target_users, + get_target_users_count, +) +from app.keyboards.admin import get_admin_communications_submenu_keyboard +from app.localization.texts import get_texts +from app.services.poll_service import send_poll_to_users +from app.utils.decorators import admin_required, error_handler +from app.utils.validators import get_html_help_text, validate_html_tags + +logger = logging.getLogger(__name__) + + +class PollCreationStates(StatesGroup): + waiting_for_title = State() + waiting_for_description = State() + waiting_for_reward = State() + waiting_for_questions = State() + + +def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + keyboard: list[list[types.InlineKeyboardButton]] = [] + + for poll in polls[:10]: + keyboard.append( + [ + types.InlineKeyboardButton( + text=f"🗳️ {poll.title[:40]}", + callback_data=f"poll_view:{poll.id}", + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), + callback_data="poll_create", + ) + ] + ) + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_submenu_communications", + ) + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def _format_reward_text(poll: Poll, language: str) -> str: + texts = get_texts(language) + if poll.reward_enabled and poll.reward_amount_kopeks > 0: + return texts.t( + "ADMIN_POLLS_REWARD_ENABLED", + "Награда: {amount}", + ).format(amount=settings.format_price(poll.reward_amount_kopeks)) + return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена") + + +def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"), + callback_data=f"poll_send:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"), + callback_data=f"poll_stats:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"poll_delete:{poll_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")], + ] + ) + + +def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), + callback_data=f"poll_target:{poll_id}:all", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), + callback_data=f"poll_target:{poll_id}:active", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), + callback_data=f"poll_target:{poll_id}:trial", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), + callback_data=f"poll_target:{poll_id}:no", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), + callback_data=f"poll_target:{poll_id}:expiring", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), + callback_data=f"poll_target:{poll_id}:expired", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), + callback_data=f"poll_target:{poll_id}:active_zero", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), + callback_data=f"poll_target:{poll_id}:trial_zero", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"), + callback_data=f"poll_custom_menu:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_view:{poll_id}", + ) + ], + ] + ) + + +def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"), + callback_data=f"poll_custom_target:{poll_id}:today", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"), + callback_data=f"poll_custom_target:{poll_id}:week", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"), + callback_data=f"poll_custom_target:{poll_id}:month", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), + callback_data=f"poll_custom_target:{poll_id}:active_today", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), + callback_data=f"poll_custom_target:{poll_id}:inactive_week", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), + callback_data=f"poll_custom_target:{poll_id}:inactive_month", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), + callback_data=f"poll_custom_target:{poll_id}:referrals", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), + callback_data=f"poll_custom_target:{poll_id}:direct", + ), + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")], + ] + ) + + +def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) -> types.InlineKeyboardMarkup: + texts = get_texts(language) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"), + callback_data=f"poll_send_confirm:{poll_id}:{target}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_send:{poll_id}", + ) + ], + ] + ) + + +@admin_required +@error_handler +async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + polls = await list_polls(db) + texts = get_texts(db_user.language) + + lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ Опросы"), ""] + if not polls: + lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет.")) + else: + for poll in polls[:10]: + reward = _format_reward_text(poll, db_user.language) + lines.append( + f"• {html.escape(poll.title)} — " + f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n" + f" {reward}" + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=_build_polls_keyboard(polls, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + 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( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_poll_title( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + if message.text == "/cancel": + await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + title = message.text.strip() + if not title: + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + return + + await state.update_data(title=title) + await state.set_state(PollCreationStates.waiting_for_description) + + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) + + +@admin_required +@error_handler +async def process_poll_description( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + description: Optional[str] + if message.text == "/skip": + description = None + else: + description = message.text.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) + ) + 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 чтобы отключить награду.", + ) + ) + + +def _parse_reward_amount(message_text: str) -> int | None: + normalized = message_text.replace(" ", "").replace(",", ".") + try: + value = Decimal(normalized) + except InvalidOperation: + return None + + if value < 0: + value = Decimal(0) + + kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + return max(0, kopeks) + + +@admin_required +@error_handler +async def process_poll_reward( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + reward_kopeks = _parse_reward_amount(message.text) + if reward_kopeks is None: + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + return + + reward_enabled = reward_kopeks > 0 + await state.update_data( + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_kopeks, + ) + await state.set_state(PollCreationStates.waiting_for_questions) + + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) + + +@admin_required +@error_handler +async def process_poll_question( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + if message.text == "/cancel": + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) + return + + if message.text == "/done": + data = await state.get_data() + questions = data.get("questions", []) + if not questions: + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), + ) + return + + title = data.get("title") + description = data.get("description") + reward_enabled = data.get("reward_enabled", False) + reward_amount = data.get("reward_amount_kopeks", 0) + + poll = await create_poll( + db, + title=title, + description=description, + reward_enabled=reward_enabled, + reward_amount_kopeks=reward_amount, + created_by=db_user.id, + 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), + parse_mode="HTML", + ) + return + + lines = [line.strip() for line in message.text.splitlines() if line.strip()] + if len(lines) < 3: + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) + ) + 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), + parse_mode="HTML", + ) + + +async def _render_poll_details(poll: Poll, language: str) -> str: + texts = get_texts(language) + lines = [f"🗳️ {html.escape(poll.title)}"] + if poll.description: + lines.append(poll.description) + + lines.append(_format_reward_text(poll, language)) + lines.append( + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format( + count=len(poll.questions) + ) + ) + + if poll.questions: + lines.append("") + lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "Вопросы:")) + for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1): + lines.append(f"{idx}. {html.escape(question.text)}") + for option in sorted(question.options, key=lambda o: o.order): + lines.append( + texts.t("ADMIN_POLLS_OPTION_BULLET", " • {option}").format( + option=html.escape(option.text) + ) + ) + + return "\n".join(lines) + + +@admin_required +@error_handler +async def show_poll_details( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + text = await _render_poll_details(poll, db_user.language) + await callback.message.edit_text( + text, + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_poll_send( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"), + reply_markup=_build_target_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_custom_target_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + await callback.message.edit_text( + get_texts(db_user.language).t( + "ADMIN_POLLS_CUSTOM_PROMPT", + "Выберите дополнительный критерий аудитории:", + ), + reply_markup=_build_custom_target_keyboard(poll_id, db_user.language), + ) + await callback.answer() + + +async def _show_send_confirmation( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + poll_id: int, + target: str, + user_count: int, +): + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + audience_name = get_target_display_name(target) + texts = get_texts(db_user.language) + confirmation_text = texts.t( + "ADMIN_POLLS_SEND_CONFIRM", + "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", + ).format(title=poll.title, audience=audience_name, count=user_count) + + await callback.message.edit_text( + confirmation_text, + reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, target = payload.split(":", 1) + poll_id = int(poll_id_str) + + user_count = await get_target_users_count(db, target) + await _show_send_confirmation(callback, db_user, db, poll_id, target, user_count) + + +@admin_required +@error_handler +async def select_custom_poll_target( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, criteria = payload.split(":", 1) + poll_id = int(poll_id_str) + + user_count = await get_custom_users_count(db, criteria) + await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count) + + +@admin_required +@error_handler +async def confirm_poll_send( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + _, payload = callback.data.split(":", 1) + poll_id_str, target = payload.split(":", 1) + poll_id = int(poll_id_str) + + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + if target.startswith("custom_"): + users = await get_custom_users(db, target.replace("custom_", "")) + else: + users = await get_target_users(db, target) + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), + parse_mode="HTML", + ) + + result = await send_poll_to_users(callback.bot, db, poll, users) + + result_text = texts.t( + "ADMIN_POLLS_SEND_RESULT", + "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", + ).format(**result) + + await callback.message.edit_text( + result_text, + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_poll_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + stats = await get_poll_statistics(db, poll_id) + texts = get_texts(db_user.language) + + reward_sum = settings.format_price(stats["reward_sum_kopeks"]) + lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 Статистика опроса"), ""] + lines.append( + texts.t( + "ADMIN_POLLS_STATS_OVERVIEW", + "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", + ).format( + total=stats["total_responses"], + completed=stats["completed_responses"], + reward=reward_sum, + ) + ) + + for question in stats["questions"]: + lines.append("") + lines.append(f"{html.escape(question['text'])}") + for option in question["options"]: + lines.append( + texts.t( + "ADMIN_POLLS_STATS_OPTION_LINE", + "• {option}: {count}", + ).format(option=html.escape(option["text"]), count=option["count"]) + ) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_poll_delete( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + poll = await get_poll_by_id(db, poll_id) + if not poll: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CONFIRM_DELETE", + "Вы уверены, что хотите удалить опрос «{title}»?", + ).format(title=poll.title), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), + callback_data=f"poll_delete_confirm:{poll_id}", + ) + ], + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data=f"poll_view:{poll_id}", + ) + ], + ] + ), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_poll_handler( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + poll_id = int(callback.data.split(":")[1]) + success = await delete_poll(db, poll_id) + texts = get_texts(db_user.language) + + if success: + await callback.message.edit_text( + texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), + reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ) + else: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_polls_panel, F.data == "admin_polls") + dp.callback_query.register(start_poll_creation, F.data == "poll_create") + dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:")) + dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:")) + dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:")) + dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:")) + dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:")) + dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:")) + dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:")) + dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:")) + dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:")) + + dp.message.register(process_poll_title, PollCreationStates.waiting_for_title) + dp.message.register(process_poll_description, PollCreationStates.waiting_for_description) + dp.message.register(process_poll_reward, PollCreationStates.waiting_for_reward) + dp.message.register(process_poll_question, PollCreationStates.waiting_for_questions) diff --git a/app/handlers/polls.py b/app/handlers/polls.py new file mode 100644 index 00000000..2572b38b --- /dev/null +++ b/app/handlers/polls.py @@ -0,0 +1,197 @@ +import asyncio +import logging +from datetime import datetime + +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.poll import ( + get_poll_response_by_id, + record_poll_answer, +) +from app.database.models import PollQuestion, User +from app.localization.texts import get_texts +from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll + +logger = logging.getLogger(__name__) + + +async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None: + try: + await asyncio.sleep(delay) + await bot.delete_message(chat_id, message_id) + except Exception as error: # pragma: no cover - cleanup best effort + logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error) + + +async def _render_question_text( + poll_title: str, + question: PollQuestion, + current_index: int, + total: int, + language: str, +) -> str: + texts = get_texts(language) + header = texts.t("POLL_QUESTION_HEADER", "Вопрос {current}/{total}").format( + current=current_index, + total=total, + ) + lines = [f"🗳️ {poll_title}", "", header, "", question.text] + return "\n".join(lines) + + +def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: + buttons: list[list[types.InlineKeyboardButton]] = [] + for option in sorted(question.options, key=lambda o: o.order): + buttons.append( + [ + types.InlineKeyboardButton( + text=option.text, + callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}", + ) + ] + ) + return types.InlineKeyboardMarkup(inline_keyboard=buttons) + + +async def handle_poll_start( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + try: + response_id = int(callback.data.split(":")[1]) + except (IndexError, ValueError): + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + response = await get_poll_response_by_id(db, response_id) + if not response or response.user_id != db_user.id: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + + if response.completed_at: + await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + return + + if not response.poll or not response.poll.questions: + await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True) + return + + if not response.started_at: + response.started_at = datetime.utcnow() + await db.commit() + + index, question = await get_next_question(response) + if not question: + await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True) + return + + question_text = await _render_question_text( + response.poll.title, + question, + index, + len(response.poll.questions), + db_user.language, + ) + + await callback.message.edit_text( + question_text, + reply_markup=_build_options_keyboard(response.id, question), + parse_mode="HTML", + ) + await callback.answer() + + +async def handle_poll_answer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + try: + _, response_id, question_id, option_id = callback.data.split(":", 3) + response_id = int(response_id) + question_id = int(question_id) + option_id = int(option_id) + except (ValueError, IndexError): + await callback.answer("❌ Некорректные данные", show_alert=True) + return + + response = await get_poll_response_by_id(db, response_id) + texts = get_texts(db_user.language) + + if not response or response.user_id != db_user.id: + await callback.answer("❌ Опрос не найден", show_alert=True) + return + + if not response.poll: + await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + return + + if response.completed_at: + await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + return + + question = next((q for q in response.poll.questions if q.id == question_id), None) + if not question: + await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True) + return + + option = await get_question_option(question, option_id) + if not option: + await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True) + return + + await record_poll_answer( + db, + response_id=response.id, + question_id=question.id, + option_id=option.id, + ) + + response = await get_poll_response_by_id(db, response.id) + index, next_question = await get_next_question(response) + + if next_question: + question_text = await _render_question_text( + response.poll.title, + next_question, + index, + len(response.poll.questions), + db_user.language, + ) + await callback.message.edit_text( + question_text, + reply_markup=_build_options_keyboard(response.id, next_question), + parse_mode="HTML", + ) + await callback.answer() + return + + response.completed_at = datetime.utcnow() + await db.commit() + + reward_amount = await reward_user_for_poll(db, response) + + thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")] + if reward_amount: + thanks_lines.append( + texts.t( + "POLL_REWARD_GRANTED", + "Награда {amount} зачислена на ваш баланс.", + ).format(amount=settings.format_price(reward_amount)) + ) + + await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + asyncio.create_task( + _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:")) + dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 75e72bc0..0fad927c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"), + callback_data="admin_polls", + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index eee72fe3..0103e8e6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,5 +1375,50 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", + "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls", + "ADMIN_POLLS_CREATE": "➕ Create poll", + "ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}", + "ADMIN_POLLS_REWARD_DISABLED": "Reward disabled", + "ADMIN_POLLS_SEND": "📤 Send", + "ADMIN_POLLS_STATS": "📊 Stats", + "ADMIN_POLLS_DELETE": "🗑️ Delete", + "ADMIN_POLLS_BACK": "⬅️ Back to list", + "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters", + "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send", + "ADMIN_POLLS_LIST_TITLE": "🗳️ Polls", + "ADMIN_POLLS_LIST_EMPTY": "No polls yet.", + "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", + "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Create poll\n\nEnter poll title:", + "ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.", + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.", + "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}", + "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.", + "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.", + "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.", + "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.", + "ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}", + "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.", + "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.", + "ADMIN_POLLS_QUESTION_LIST_HEADER": "Questions:", + "ADMIN_POLLS_OPTION_BULLET": " • {option}", + "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:", + "ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:", + "ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}", + "ADMIN_POLLS_SENDING": "📤 Sending poll...", + "ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}", + "ADMIN_POLLS_STATS_HEADER": "📊 Poll statistics", + "ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}", + "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", + "ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?", + "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", + "POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.", + "POLL_INVITATION_START": "Tap the button below to answer the poll.", + "POLL_START_BUTTON": "📝 Take the poll", + "POLL_QUESTION_HEADER": "Question {current}/{total}", + "POLL_ALREADY_COMPLETED": "You have already completed this poll.", + "POLL_EMPTY": "Poll is not available yet.", + "POLL_ERROR": "Unable to process the poll. Please try again later.", + "POLL_COMPLETED": "🙏 Thanks for completing the poll!", + "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ca6da8ca..20f6ed7e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,5 +1375,50 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", + "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы", + "ADMIN_POLLS_CREATE": "➕ Создать опрос", + "ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}", + "ADMIN_POLLS_REWARD_DISABLED": "Награда отключена", + "ADMIN_POLLS_SEND": "📤 Отправить", + "ADMIN_POLLS_STATS": "📊 Статистика", + "ADMIN_POLLS_DELETE": "🗑️ Удалить", + "ADMIN_POLLS_BACK": "⬅️ К списку", + "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям", + "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить", + "ADMIN_POLLS_LIST_TITLE": "🗳️ Опросы", + "ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.", + "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", + "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Создание опроса\n\nВведите заголовок опроса:", + "ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.", + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}", + "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.", + "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.", + "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.", + "ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", + "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.", + "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + "ADMIN_POLLS_QUESTION_LIST_HEADER": "Вопросы:", + "ADMIN_POLLS_OPTION_BULLET": " • {option}", + "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:", + "ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:", + "ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", + "ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...", + "ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", + "ADMIN_POLLS_STATS_HEADER": "📊 Статистика опроса", + "ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", + "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}", + "ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?", + "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", + "POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.", + "POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.", + "POLL_START_BUTTON": "📝 Пройти опрос", + "POLL_QUESTION_HEADER": "Вопрос {current}/{total}", + "POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.", + "POLL_EMPTY": "Опрос пока недоступен.", + "POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.", + "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", + "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." } diff --git a/app/services/poll_service.py b/app/services/poll_service.py new file mode 100644 index 00000000..5e8b6187 --- /dev/null +++ b/app/services/poll_service.py @@ -0,0 +1,179 @@ +import asyncio +import logging +from typing import Iterable + +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import add_user_balance +from app.database.models import ( + Poll, + PollOption, + PollQuestion, + PollResponse, + TransactionType, + User, +) +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) + + lines: list[str] = [f"🗳️ {poll.title}"] + if poll.description: + lines.append(poll.description) + + if poll.reward_enabled and poll.reward_amount_kopeks > 0: + reward_line = texts.t( + "POLL_INVITATION_REWARD", + "🎁 За участие вы получите {amount}.", + ).format(amount=settings.format_price(poll.reward_amount_kopeks)) + lines.append(reward_line) + + lines.append( + texts.t( + "POLL_INVITATION_START", + "Нажмите кнопку ниже, чтобы пройти опрос.", + ) + ) + + return "\n\n".join(lines) + + +def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"), + callback_data=f"poll_start:{response_id}", + ) + ] + ] + ) + + +async def send_poll_to_users( + bot: Bot, + db: AsyncSession, + poll: Poll, + users: Iterable[User], +) -> dict: + sent = 0 + failed = 0 + skipped = 0 + + for index, user in enumerate(users, start=1): + existing_response = await db.execute( + select(PollResponse.id).where( + and_( + PollResponse.poll_id == poll.id, + PollResponse.user_id == user.id, + ) + ) + ) + if existing_response.scalar_one_or_none(): + skipped += 1 + continue + + response = PollResponse( + poll_id=poll.id, + user_id=user.id, + ) + db.add(response) + + try: + await db.flush() + + text = _build_poll_invitation_text(poll, user) + keyboard = build_start_keyboard(response.id, user.language) + + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=keyboard, + parse_mode="HTML", + disable_web_page_preview=True, + ) + + await db.commit() + sent += 1 + + if index % 20 == 0: + await asyncio.sleep(1) + except Exception as error: # pragma: no cover - defensive logging + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll.id, + user.telegram_id, + error, + ) + await db.rollback() + + return { + "sent": sent, + "failed": failed, + "skipped": skipped, + "total": sent + failed + skipped, + } + + +async def reward_user_for_poll( + db: AsyncSession, + response: PollResponse, +) -> int: + poll = response.poll + if not poll.reward_enabled or poll.reward_amount_kopeks <= 0: + return 0 + + if response.reward_given: + return response.reward_amount_kopeks + + user = response.user + description = f"Награда за участие в опросе \"{poll.title}\"" + + success = await add_user_balance( + db, + user, + poll.reward_amount_kopeks, + description, + transaction_type=TransactionType.POLL_REWARD, + ) + + if not success: + return 0 + + response.reward_given = True + response.reward_amount_kopeks = poll.reward_amount_kopeks + await db.commit() + + return poll.reward_amount_kopeks + + +async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]: + if not response.poll or not response.poll.questions: + return None, None + + answered_question_ids = {answer.question_id for answer in response.answers} + ordered_questions = sorted(response.poll.questions, key=lambda q: q.order) + + for index, question in enumerate(ordered_questions, start=1): + if question.id not in answered_question_ids: + return index, question + + return None, None + + +async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None: + for option in question.options: + if option.id == option_id: + return option + return None diff --git a/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py new file mode 100644 index 00000000..3b240735 --- /dev/null +++ b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py @@ -0,0 +1,155 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "9f0f2d5a1c7b" +down_revision: Union[str, None] = "8fd1e338eb45" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "polls", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "reward_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index("ix_polls_id", "polls", ["id"]) + + op.create_table( + "poll_questions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), + ) + op.create_index("ix_poll_questions_id", "poll_questions", ["id"]) + op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"]) + + op.create_table( + "poll_options", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column( + "order", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), + ) + op.create_index("ix_poll_options_id", "poll_options", ["id"]) + op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"]) + + op.create_table( + "poll_responses", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("poll_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "sent_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column( + "reward_given", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + sa.Column( + "reward_amount_kopeks", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), + ) + op.create_index("ix_poll_responses_id", "poll_responses", ["id"]) + op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"]) + op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"]) + + op.create_table( + "poll_answers", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("response_id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.Integer(), nullable=False), + sa.Column("option_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"), + sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), + ) + op.create_index("ix_poll_answers_id", "poll_answers", ["id"]) + op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"]) + op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"]) + + +def downgrade() -> None: + op.drop_index("ix_poll_answers_question_id", table_name="poll_answers") + op.drop_index("ix_poll_answers_response_id", table_name="poll_answers") + op.drop_index("ix_poll_answers_id", table_name="poll_answers") + op.drop_table("poll_answers") + + op.drop_index("ix_poll_responses_user_id", table_name="poll_responses") + op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses") + op.drop_index("ix_poll_responses_id", table_name="poll_responses") + op.drop_table("poll_responses") + + op.drop_index("ix_poll_options_question_id", table_name="poll_options") + op.drop_index("ix_poll_options_id", table_name="poll_options") + op.drop_table("poll_options") + + op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions") + op.drop_index("ix_poll_questions_id", table_name="poll_questions") + op.drop_table("poll_questions") + + op.drop_index("ix_polls_id", table_name="polls") + op.drop_table("polls") From 8105fe285dd22b765ab8aad9320617f55ab661c3 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:22:31 +0300 Subject: [PATCH 09/31] Fix poll sending flow and tidy poll creation prompts --- app/handlers/admin/polls.py | 439 ++++++++++++++++++++++++++++------- app/services/poll_service.py | 17 +- 2 files changed, 370 insertions(+), 86 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..8c8562da 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,175 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +447,17 @@ 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 state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + form_message_id=callback.message.message_id, + ) + + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +471,50 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -333,36 +526,55 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) 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 чтобы отключить награду.", - ) - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +600,38 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return reward_enabled = reward_kopeks > 0 @@ -409,16 +641,11 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -430,21 +657,40 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await message.answer(error_text) return title = data.get("title") @@ -452,6 +698,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +710,48 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await message.answer( + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return question_text = lines[0] @@ -496,13 +761,21 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +942,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +964,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_value, 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..6a72f644 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -70,11 +71,19 @@ async def send_poll_to_users( failed = 0 skipped = 0 + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +93,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +101,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -112,7 +121,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From 8c47f3c49bf261d9e7914235ef33a28ed7647d5e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:26:04 +0300 Subject: [PATCH 10/31] Revert "Fix poll sending flow and tidy poll creation prompts" --- app/handlers/admin/polls.py | 441 +++++++---------------------------- app/services/poll_service.py | 17 +- 2 files changed, 87 insertions(+), 371 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 8c8562da..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,175 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -447,17 +277,13 @@ 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=[], - form_chat_id=callback.message.chat.id, - form_message_id=callback.message.message_id, - ) - - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") + await state.update_data(questions=[]) await callback.message.edit_text( - form_text, + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) await callback.answer() @@ -471,50 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -526,55 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -600,38 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -641,11 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -657,40 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await message.answer(error_text) return title = data.get("title") @@ -698,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -710,48 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await message.answer( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) return question_text = lines[0] @@ -761,21 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -942,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -964,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 6a72f644..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -71,19 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -93,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -101,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -121,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From de6557d7e7d38c904331e60edc74cd0b064a6948 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:26:59 +0300 Subject: [PATCH 11/31] Fix poll sending session usage and clean creation prompts --- app/handlers/admin/polls.py | 329 ++++++++++++++++++++++++++--------- app/services/poll_service.py | 28 ++- 2 files changed, 272 insertions(+), 85 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..7e26a421 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -40,6 +41,156 @@ class PollCreationStates(StatesGroup): waiting_for_questions = State() +async def _delete_message_safely(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest: + pass + except Exception as error: # pragma: no cover - defensive logging + logger.debug("Failed to delete message: %s", error) + + +def _render_poll_creation_message( + texts, + data: dict, + current_state: str | None, + status_message: str | None = None, +) -> str: + missing = object() + + title = data.get("title") + description = data.get("description", missing) + reward_enabled = data.get("reward_enabled", missing) + reward_amount = data.get("reward_amount_kopeks", 0) + questions = data.get("questions", []) + + lines: list[str] = [texts.t("ADMIN_POLLS_CREATION_HEADER", "🗳️ Создание опроса"), ""] + + lines.append(texts.t("ADMIN_POLLS_CREATION_TITLE_LABEL", "Введите заголовок опроса:")) + if title: + lines.append(f"- {html.escape(title)}") + lines.append("") + + lines.append( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_LABEL", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + ) + lines.append("") + lines.append(get_html_help_text()) + if description is not missing: + lines.append("") + if description: + lines.append(f"- {description}") + else: + lines.append( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "- Описание пропущено", + ) + ) + lines.append("") + + lines.append( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + ) + if reward_enabled is not missing: + if reward_enabled: + lines.append( + texts.t("ADMIN_POLLS_CREATION_REWARD_VALUE", "- {amount}").format( + amount=settings.format_price(reward_amount) + ) + ) + else: + lines.append(texts.t("ADMIN_POLLS_CREATION_REWARD_DISABLED_NOTE", "- 0")) + lines.append("") + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + lines.append(question_prompt) + if questions: + for index, question in enumerate(questions, start=1): + lines.append("") + lines.append(f"{index}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f"• {html.escape(option)}") + + if status_message: + lines.extend(["", status_message]) + + return "\n".join(lines) + + +async def _edit_prompt_message( + bot, + data: dict, + text: str, + reply_markup: types.InlineKeyboardMarkup | None = None, +) -> None: + chat_id = data.get("prompt_chat_id") + message_id = data.get("prompt_message_id") + if not chat_id or not message_id: + return + + try: + await bot.edit_message_text( + text=text, + chat_id=chat_id, + message_id=message_id, + parse_mode="HTML", + reply_markup=reply_markup, + disable_web_page_preview=True, + ) + except TelegramBadRequest: + pass + + +async def _update_creation_status( + bot, + state: FSMContext, + texts, + status_message: str | None = None, +) -> None: + data = await state.get_data() + current_state = await state.get_state() + text = _render_poll_creation_message(texts, data, current_state, status_message=status_message) + await _edit_prompt_message(bot, data, text) + + +async def _cancel_poll_creation( + message: types.Message, + state: FSMContext, + texts, + language: str, +) -> None: + await _delete_message_safely(message) + data = await state.get_data() + prompt_info = { + "prompt_chat_id": data.get("prompt_chat_id"), + "prompt_message_id": data.get("prompt_message_id"), + } + await state.clear() + + cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") + keyboard = get_admin_communications_submenu_keyboard(language) + + if prompt_info["prompt_chat_id"] and prompt_info["prompt_message_id"]: + await _edit_prompt_message(message.bot, prompt_info, cancel_text, reply_markup=keyboard) + else: + await message.answer(cancel_text, reply_markup=keyboard) + + def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) keyboard: list[list[types.InlineKeyboardButton]] = [] @@ -277,15 +428,13 @@ 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( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), - parse_mode="HTML", + await state.update_data( + questions=[], + prompt_message_id=callback.message.message_id, + prompt_chat_id=callback.message.chat.id, ) + + await _update_creation_status(callback.bot, state, texts) await callback.answer() @@ -297,31 +446,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) + await _cancel_poll_creation(message, state, texts, db_user.language) return title = message.text.strip() + await _delete_message_safely(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ), + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) + await _update_creation_status(message.bot, state, texts) @admin_required @@ -335,11 +484,7 @@ async def process_poll_description( texts = get_texts(db_user.language) if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) + await _cancel_poll_creation(message, state, texts, db_user.language) return description: Optional[str] @@ -349,20 +494,23 @@ async def process_poll_description( description = message.text.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) + await _delete_message_safely(message) + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( + "ADMIN_POLLS_CREATION_INVALID_HTML", + "❌ Ошибка в HTML: {error}", + ).format(error=error_message), ) return + await _delete_message_safely(message) 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 чтобы отключить награду.", - ) - ) + await _update_creation_status(message.bot, state, texts) def _parse_reward_amount(message_text: str) -> int | None: @@ -390,16 +538,22 @@ async def process_poll_reward( texts = get_texts(db_user.language) if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) + await _cancel_poll_creation(message, state, texts, db_user.language) return + await _delete_message_safely(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ), + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +563,7 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + await _update_creation_status(message.bot, state, texts) @admin_required @@ -431,19 +576,23 @@ async def process_poll_question( ): texts = get_texts(db_user.language) if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - ) + await _cancel_poll_creation(message, state, texts, db_user.language) return + await _delete_message_safely(message) + if message.text == "/done": data = await state.get_data() questions = data.get("questions", []) if not questions: - await message.answer( - texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( + "ADMIN_POLLS_CREATION_NEEDS_QUESTION", + "❌ Добавьте хотя бы один вопрос.", + ), ) return @@ -462,30 +611,48 @@ async def process_poll_question( questions=questions, ) + reward_text = _format_reward_text(poll, db_user.language) + prompt_info = { + "prompt_chat_id": data.get("prompt_chat_id"), + "prompt_message_id": data.get("prompt_message_id"), + } + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + success_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", + ).format( + title=poll.title, + count=len(poll.questions), + reward=reward_text, + ) + 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), - parse_mode="HTML", - ) + if prompt_info["prompt_chat_id"] and prompt_info["prompt_message_id"]: + await _edit_prompt_message( + message.bot, + prompt_info, + success_text, + reply_markup=keyboard, + ) + else: + await message.answer( + success_text, + reply_markup=keyboard, + parse_mode="HTML", + ) return lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - await message.answer( - texts.t( + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( "ADMIN_POLLS_CREATION_MIN_OPTIONS", "❌ Нужен вопрос и минимум два варианта ответа.", - ) + ), ) return @@ -496,12 +663,14 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - await message.answer( - texts.t( + await _update_creation_status( + message.bot, + state, + texts, + status_message=texts.t( "ADMIN_POLLS_CREATION_ADDED_QUESTION", "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=question_text), - parse_mode="HTML", + ).format(question=html.escape(question_text)), ) @@ -689,7 +858,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..4464d0d3 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(slots=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}"] @@ -70,11 +80,19 @@ async def send_poll_to_users( failed = 0 skipped = 0 + poll_snapshot = _PollSnapshot( + id=poll.id, + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_snapshot.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_snapshot.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_snapshot, 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_snapshot.id, user.telegram_id, error, ) From 0af7e4ac687154bcda0a040954f90c3769a1046d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:29:23 +0300 Subject: [PATCH 12/31] Revert "Fix poll sending DB session usage and clean poll creation chat" --- app/handlers/admin/polls.py | 329 +++++++++-------------------------- app/services/poll_service.py | 28 +-- 2 files changed, 85 insertions(+), 272 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 7e26a421..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -4,7 +4,6 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional from aiogram import Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -41,156 +40,6 @@ class PollCreationStates(StatesGroup): waiting_for_questions = State() -async def _delete_message_safely(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest: - pass - except Exception as error: # pragma: no cover - defensive logging - logger.debug("Failed to delete message: %s", error) - - -def _render_poll_creation_message( - texts, - data: dict, - current_state: str | None, - status_message: str | None = None, -) -> str: - missing = object() - - title = data.get("title") - description = data.get("description", missing) - reward_enabled = data.get("reward_enabled", missing) - reward_amount = data.get("reward_amount_kopeks", 0) - questions = data.get("questions", []) - - lines: list[str] = [texts.t("ADMIN_POLLS_CREATION_HEADER", "🗳️ Создание опроса"), ""] - - lines.append(texts.t("ADMIN_POLLS_CREATION_TITLE_LABEL", "Введите заголовок опроса:")) - if title: - lines.append(f"- {html.escape(title)}") - lines.append("") - - lines.append( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_LABEL", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - ) - lines.append("") - lines.append(get_html_help_text()) - if description is not missing: - lines.append("") - if description: - lines.append(f"- {description}") - else: - lines.append( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "- Описание пропущено", - ) - ) - lines.append("") - - lines.append( - texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - ) - if reward_enabled is not missing: - if reward_enabled: - lines.append( - texts.t("ADMIN_POLLS_CREATION_REWARD_VALUE", "- {amount}").format( - amount=settings.format_price(reward_amount) - ) - ) - else: - lines.append(texts.t("ADMIN_POLLS_CREATION_REWARD_DISABLED_NOTE", "- 0")) - lines.append("") - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - lines.append(question_prompt) - if questions: - for index, question in enumerate(questions, start=1): - lines.append("") - lines.append(f"{index}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f"• {html.escape(option)}") - - if status_message: - lines.extend(["", status_message]) - - return "\n".join(lines) - - -async def _edit_prompt_message( - bot, - data: dict, - text: str, - reply_markup: types.InlineKeyboardMarkup | None = None, -) -> None: - chat_id = data.get("prompt_chat_id") - message_id = data.get("prompt_message_id") - if not chat_id or not message_id: - return - - try: - await bot.edit_message_text( - text=text, - chat_id=chat_id, - message_id=message_id, - parse_mode="HTML", - reply_markup=reply_markup, - disable_web_page_preview=True, - ) - except TelegramBadRequest: - pass - - -async def _update_creation_status( - bot, - state: FSMContext, - texts, - status_message: str | None = None, -) -> None: - data = await state.get_data() - current_state = await state.get_state() - text = _render_poll_creation_message(texts, data, current_state, status_message=status_message) - await _edit_prompt_message(bot, data, text) - - -async def _cancel_poll_creation( - message: types.Message, - state: FSMContext, - texts, - language: str, -) -> None: - await _delete_message_safely(message) - data = await state.get_data() - prompt_info = { - "prompt_chat_id": data.get("prompt_chat_id"), - "prompt_message_id": data.get("prompt_message_id"), - } - await state.clear() - - cancel_text = texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено.") - keyboard = get_admin_communications_submenu_keyboard(language) - - if prompt_info["prompt_chat_id"] and prompt_info["prompt_message_id"]: - await _edit_prompt_message(message.bot, prompt_info, cancel_text, reply_markup=keyboard) - else: - await message.answer(cancel_text, reply_markup=keyboard) - - def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup: texts = get_texts(language) keyboard: list[list[types.InlineKeyboardButton]] = [] @@ -428,13 +277,15 @@ 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=[], - prompt_message_id=callback.message.message_id, - prompt_chat_id=callback.message.chat.id, - ) + await state.update_data(questions=[]) - await _update_creation_status(callback.bot, state, texts) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), + parse_mode="HTML", + ) await callback.answer() @@ -446,31 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - if message.text == "/cancel": - await _cancel_poll_creation(message, state, texts, db_user.language) + await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return title = message.text.strip() - await _delete_message_safely(message) - if not title: - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ), - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - await _update_creation_status(message.bot, state, texts) + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -484,7 +335,11 @@ async def process_poll_description( texts = get_texts(db_user.language) if message.text == "/cancel": - await _cancel_poll_creation(message, state, texts, db_user.language) + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] @@ -494,23 +349,20 @@ async def process_poll_description( description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - await _delete_message_safely(message) - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message), + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) ) return - await _delete_message_safely(message) await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - await _update_creation_status(message.bot, state, texts) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -538,22 +390,16 @@ async def process_poll_reward( texts = get_texts(db_user.language) if message.text == "/cancel": - await _cancel_poll_creation(message, state, texts, db_user.language) + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - await _delete_message_safely(message) - reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ), - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -563,7 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - await _update_creation_status(message.bot, state, texts) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -576,23 +431,19 @@ async def process_poll_question( ): texts = get_texts(db_user.language) if message.text == "/cancel": - await _cancel_poll_creation(message, state, texts, db_user.language) + await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - await _delete_message_safely(message) - if message.text == "/done": data = await state.get_data() questions = data.get("questions", []) if not questions: - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", - ), + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) return @@ -611,48 +462,30 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - prompt_info = { - "prompt_chat_id": data.get("prompt_chat_id"), - "prompt_message_id": data.get("prompt_message_id"), - } - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - success_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}", - ).format( - title=poll.title, - count=len(poll.questions), - reward=reward_text, - ) - await state.clear() - if prompt_info["prompt_chat_id"] and prompt_info["prompt_message_id"]: - await _edit_prompt_message( - message.bot, - prompt_info, - success_text, - reply_markup=keyboard, - ) - else: - await message.answer( - success_text, - reply_markup=keyboard, - parse_mode="HTML", - ) + 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), + parse_mode="HTML", + ) return lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( + await message.answer( + texts.t( "ADMIN_POLLS_CREATION_MIN_OPTIONS", "❌ Нужен вопрос и минимум два варианта ответа.", - ), + ) ) return @@ -663,14 +496,12 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - await _update_creation_status( - message.bot, - state, - texts, - status_message=texts.t( + await message.answer( + texts.t( "ADMIN_POLLS_CREATION_ADDED_QUESTION", "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)), + ).format(question=question_text), + parse_mode="HTML", ) @@ -858,7 +689,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 4464d0d3..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from dataclasses import dataclass from typing import Iterable from aiogram import Bot @@ -23,16 +22,7 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -@dataclass(slots=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: +def _build_poll_invitation_text(poll: Poll, user: User) -> str: texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] @@ -80,19 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_snapshot = _PollSnapshot( - id=poll.id, - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_snapshot.id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -102,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_snapshot.id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -110,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -130,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_snapshot.id, + poll.id, user.telegram_id, error, ) From b1ff964dd6e8cf557640c704a817792eb3d29070 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:29:59 +0300 Subject: [PATCH 13/31] Fix poll sending session access and clean poll creation chat --- app/handlers/admin/polls.py | 368 +++++++++++++++++++++++++++++------ app/services/poll_service.py | 28 ++- 2 files changed, 327 insertions(+), 69 deletions(-) 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, ) From 104d98542e4fe28ec4f91584f5069fcfec6ebbd0 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:31:20 +0300 Subject: [PATCH 14/31] Revert "Fix poll delivery session usage and tidy poll creation prompts" --- app/handlers/admin/polls.py | 368 ++++++----------------------------- app/services/poll_service.py | 28 +-- 2 files changed, 69 insertions(+), 327 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 89c9a867..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import MessageNotModified, TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -41,65 +40,6 @@ 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]] = [] @@ -337,6 +277,7 @@ 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( @@ -345,11 +286,6 @@ 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() @@ -361,66 +297,31 @@ async def process_poll_title( state: FSMContext, 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) - - user_input = (message.text or "").strip() - - if user_input == "/cancel": + if message.text == "/cancel": await state.clear() - 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, + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - parse_mode="HTML", ) return - 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) + title = message.text.strip() + if not title: + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return - await state.update_data(title=user_input) + await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - prompt_body = ( + texts = get_texts(db_user.language) + await message.answer( 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), + + f"\n\n{get_html_help_text()}", parse_mode="HTML", ) - await state.update_data(prompt_message_id=new_message_id, prompt_chat_id=prompt_chat_id) @admin_required @@ -432,74 +333,36 @@ 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) - user_input = message.text or "" - - if user_input == "/cancel": + if message.text == "/cancel": await state.clear() - 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, + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - parse_mode="HTML", ) return - await _delete_user_message(message) - - if user_input == "/skip": - description: Optional[str] = None + description: Optional[str] + if message.text == "/skip": + description = None else: - description = user_input.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - 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()}" + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) ) - 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) - prompt_body = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - ( - "Укажите награду за прохождение опроса (в рублях).\n" - "0 — без награды. Можно использовать дробные значения.\n" - "Например: 0, 0.5, 10" - ), + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) ) - 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: @@ -525,50 +388,18 @@ 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) - user_input = message.text or "" - - if user_input == "/cancel": + if message.text == "/cancel": await state.clear() - 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, + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - parse_mode="HTML", ) return - await _delete_user_message(message) - - reward_kopeks = _parse_reward_amount(user_input) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - 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) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -587,14 +418,7 @@ async def process_poll_reward( "Отправьте /done, когда вопросы будут добавлены." ), ) - 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) + await message.answer(prompt) @admin_required @@ -606,52 +430,21 @@ async def process_poll_question( 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) - - user_input = message.text or "" - - if user_input == "/cancel": + if message.text == "/cancel": await state.clear() - 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, + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - parse_mode="HTML", ) return - await _delete_user_message(message) - - if user_input == "/done": + if message.text == "/done": + data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + 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") @@ -669,80 +462,47 @@ async def process_poll_question( questions=questions, ) + await state.clear() + reward_text = _format_reward_text(poll, 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, + 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), parse_mode="HTML", ) - await state.clear() return - lines = [line.strip() for line in user_input.splitlines() if line.strip()] + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + await message.answer( + 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) - 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), + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), 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: @@ -929,7 +689,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 b6862e43..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from dataclasses import dataclass from typing import Iterable from aiogram import Bot @@ -23,16 +22,7 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -@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: +def _build_poll_invitation_text(poll: Poll, user: User) -> str: texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] @@ -76,14 +66,6 @@ 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 @@ -92,7 +74,7 @@ async def send_poll_to_users( existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_info.id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -102,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_info.id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -110,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_info, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -130,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_info.id, + poll.id, user.telegram_id, error, ) From 70337ac3105244a67605a481878e8686a20b5a26 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:32:40 +0300 Subject: [PATCH 15/31] Revert "Revert "Fix poll sending flow and tidy poll creation prompts"" --- app/handlers/admin/polls.py | 439 ++++++++++++++++++++++++++++------- app/services/poll_service.py | 17 +- 2 files changed, 370 insertions(+), 86 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..8c8562da 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,175 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +447,17 @@ 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 state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + form_message_id=callback.message.message_id, + ) + + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +471,50 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -333,36 +526,55 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) 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 чтобы отключить награду.", - ) - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +600,38 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return reward_enabled = reward_kopeks > 0 @@ -409,16 +641,11 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -430,21 +657,40 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await message.answer(error_text) return title = data.get("title") @@ -452,6 +698,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +710,48 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await message.answer( + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await message.answer(error_text) return question_text = lines[0] @@ -496,13 +761,21 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +942,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +964,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_value, 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..6a72f644 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -70,11 +71,19 @@ async def send_poll_to_users( failed = 0 skipped = 0 + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +93,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +101,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -112,7 +121,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From 9ed11292687ee17e78931b39f31c18481cf116da Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:45:35 +0300 Subject: [PATCH 16/31] Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts""" --- app/handlers/admin/polls.py | 441 +++++++---------------------------- app/services/poll_service.py | 17 +- 2 files changed, 87 insertions(+), 371 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 8c8562da..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,175 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -447,17 +277,13 @@ 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=[], - form_chat_id=callback.message.chat.id, - form_message_id=callback.message.message_id, - ) - - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") + await state.update_data(questions=[]) await callback.message.edit_text( - form_text, + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) await callback.answer() @@ -471,50 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -526,55 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -600,38 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -641,11 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -657,40 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await message.answer(error_text) return title = data.get("title") @@ -698,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -710,48 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await message.answer( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await message.answer(error_text) return question_text = lines[0] @@ -761,21 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -942,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -964,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 6a72f644..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -71,19 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -93,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -101,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -121,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From b510ffb7d8d470233840a69c8c2959e6808cd331 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:46:11 +0300 Subject: [PATCH 17/31] Stabilize poll creation UI and sending --- app/handlers/admin/polls.py | 478 +++++++++++++++++++++++++++++------ app/services/poll_service.py | 43 +++- 2 files changed, 433 insertions(+), 88 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..efcc31eb 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,209 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + + try: + await bot.delete_message(chat_id=chat_id, message_id=message_id) + except TelegramBadRequest: + # Сообщение уже удалено или нельзя удалить — продолжаем без ошибок + pass + except Exception as delete_error: # pragma: no cover - defensive logging + logger.debug( + "Не удалось удалить старое сообщение создания опроса %s: %s", + message_id, + delete_error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + + if not chat_id: + return False + + try: + new_message = await bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + await state.update_data( + form_chat_id=new_message.chat.id, + form_message_id=new_message.message_id, + ) + return True + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Не удалось отправить сообщение создания опроса в чат %s: %s", + chat_id, + error, + ) + return False + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,15 +481,24 @@ 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 state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + ) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") + + form_message = await callback.message.answer( + form_text, parse_mode="HTML", ) + await state.update_data( + form_chat_id=form_message.chat.id, + form_message_id=form_message.message_id, + ) + + await _safe_delete_message(callback.message) await callback.answer() @@ -297,31 +510,50 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + + state_data = await state.get_data() if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(error_text) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -333,36 +565,55 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(error_text) 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 чтобы отключить награду.", - ) - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +639,38 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(error_text) return reward_enabled = reward_kopeks > 0 @@ -409,16 +680,11 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -430,21 +696,40 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(error_text) return title = data.get("title") @@ -462,31 +747,48 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await message.answer( + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(error_text) return question_text = lines[0] @@ -496,13 +798,21 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, state, form_text) + if not updated: + await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +979,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +1001,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_value, 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..fe640784 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -17,6 +18,7 @@ from app.database.models import ( TransactionType, User, ) +from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts logger = logging.getLogger(__name__) @@ -70,11 +72,42 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots: list[SimpleNamespace] = [] + for raw_user in users: + try: + user_id = getattr(raw_user, "id") + telegram_id = getattr(raw_user, "telegram_id") + except AttributeError as error: # pragma: no cover - defensive logging + logger.warning("Пропускаем пользователя без обязательных полей: %s", error) + continue + + language = getattr(raw_user, "language", None) or DEFAULT_LANGUAGE + user_snapshots.append( + SimpleNamespace(id=user_id, telegram_id=telegram_id, language=language) + ) + + for index, user in enumerate(user_snapshots, start=1): + if not user.telegram_id: + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s: у пользователя %s отсутствует telegram_id", + poll_id, + user.id, + ) + continue + existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +117,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +125,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -112,7 +145,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From d281eeb69d0e4a698c305dcbde0cfdbca249062f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:46:46 +0300 Subject: [PATCH 18/31] Stabilize poll creation UI and delivery loop --- app/handlers/admin/polls.py | 561 +++++++++++++++++++++++++++++------ app/services/poll_service.py | 32 +- 2 files changed, 503 insertions(+), 90 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..977c6025 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,221 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.answer( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,15 +493,22 @@ 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( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), - parse_mode="HTML", + await state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + form_message_id=callback.message.message_id, ) + + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") + updated = await _edit_creation_message(callback.bot, state_data, form_text) + if not updated: + await _send_creation_message( + callback.message, + state, + form_text, + parse_mode="HTML", + ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +1062,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +1084,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_value, 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..88f0a648 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -22,8 +23,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +71,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_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_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_snapshot, user.language) 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_id, user.telegram_id, error, ) From cc42d33fbefc6a31cf3ec3c327ed4079b5ce5927 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:51:19 +0300 Subject: [PATCH 19/31] Revert "Stabilize poll creation UI and delivery loop" --- app/handlers/admin/polls.py | 563 ++++++----------------------------- app/services/poll_service.py | 32 +- 2 files changed, 91 insertions(+), 504 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 977c6025..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,221 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -async def _send_creation_message( - message: types.Message, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> types.Message: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", - message_id, - error, - ) - - sent_message = await message.answer( - text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - - return sent_message - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -493,22 +277,15 @@ 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=[], - form_chat_id=callback.message.chat.id, - form_message_id=callback.message.message_id, - ) + await state.update_data(questions=[]) - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") - updated = await _edit_creation_message(callback.bot, state_data, form_text) - if not updated: - await _send_creation_message( - callback.message, - state, - form_text, - parse_mode="HTML", - ) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), + parse_mode="HTML", + ) await callback.answer() @@ -520,66 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -591,71 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -681,49 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -733,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -754,51 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) return title = data.get("title") @@ -806,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -818,55 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await _send_creation_message( - message, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + ) return question_text = lines[0] @@ -876,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1062,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -1084,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 88f0a648..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -23,8 +22,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, language: str) -> str: - texts = get_texts(language) +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -71,28 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots = [ - SimpleNamespace( - id=user.id, - telegram_id=user.telegram_id, - language=user.language, - ) - for user in users - ] - - for index, user in enumerate(user_snapshots, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -102,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -110,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user.language) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -130,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From 583e01f5e6770ddb057418029c00d1a13d5cd28f Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:51:48 +0300 Subject: [PATCH 20/31] Stabilize poll creation prompts and delivery --- app/handlers/admin/polls.py | 510 +++++++++++++++++++++++++++++------ app/services/poll_service.py | 55 +++- 2 files changed, 478 insertions(+), 87 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..ddbb3305 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,193 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", + fallback_chat_id: int | None = None, +) -> bool: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") or fallback_chat_id + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + try: + await bot.delete_message(chat_id, message_id) + except TelegramBadRequest: + pass + await state.update_data(form_message_id=None) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + await state.update_data(form_message_id=None) + + if not chat_id: + return False + + sent_message = await bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + return True + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ) + + lines: list[str] = [title_prompt] + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,15 +465,24 @@ 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( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), - parse_mode="HTML", + await state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + form_message_id=callback.message.message_id, ) + + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") + + updated = await _edit_creation_message( + callback.bot, + state, + form_text, + parse_mode="HTML", + fallback_chat_id=callback.message.chat.id, + ) + if not updated: + await _safe_delete_message(callback.message) await callback.answer() @@ -297,31 +494,61 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(error_text) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, ) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -333,36 +560,66 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, ) + if not updated: + await message.answer(error_text) 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 чтобы отключить награду.", - ) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, ) + if not updated: + await message.answer(form_text, parse_mode="HTML") def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +645,44 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(error_text) return reward_enabled = reward_kopeks > 0 @@ -409,16 +692,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, ) - await message.answer(prompt) + if not updated: + await message.answer(form_text, parse_mode="HTML") @admin_required @@ -430,21 +713,46 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(error_text) return title = data.get("title") @@ -462,31 +770,54 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), - parse_mode="HTML", + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + state, + result_text, + reply_markup=keyboard, + parse_mode="HTML", + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer( + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(error_text) return question_text = lines[0] @@ -496,13 +827,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message( + message.bot, + state, + form_text, + fallback_chat_id=message.chat.id, + ) + if not updated: + await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +1013,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +1035,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_value, 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..4213e60e 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -22,6 +23,39 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) +def _snapshot_users(users: Iterable[User]) -> tuple[list[SimpleNamespace], int]: + snapshots: list[SimpleNamespace] = [] + dropped = 0 + + for user in users: + if isinstance(user, dict): + user_id = user.get("id") + telegram_id = user.get("telegram_id") + language = user.get("language") + else: + user_id = getattr(user, "id", None) + telegram_id = getattr(user, "telegram_id", None) + language = getattr(user, "language", None) + + if user_id is None or telegram_id is None: + logger.warning( + "Пропускаю пользователя без id или telegram_id: %s", + getattr(user, "id", user), + ) + dropped += 1 + continue + + snapshots.append( + SimpleNamespace( + id=user_id, + telegram_id=telegram_id, + language=language or settings.DEFAULT_LANGUAGE, + ) + ) + + return snapshots, dropped + + def _build_poll_invitation_text(poll: Poll, user: User) -> str: texts = get_texts(user.language) @@ -70,11 +104,22 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_records, dropped = _snapshot_users(users) + skipped += dropped + + for index, user in enumerate(user_records, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +129,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +137,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -112,7 +157,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From d3399c569c639f0b6463f6c1df4a642e3e146669 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:55:06 +0300 Subject: [PATCH 21/31] Revert "Fix poll creation wizard updates and poll delivery" --- app/handlers/admin/polls.py | 510 ++++++----------------------------- app/services/poll_service.py | 55 +--- 2 files changed, 87 insertions(+), 478 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index ddbb3305..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,193 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", - fallback_chat_id: int | None = None, -) -> bool: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") or fallback_chat_id - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - try: - await bot.delete_message(chat_id, message_id) - except TelegramBadRequest: - pass - await state.update_data(form_message_id=None) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - await state.update_data(form_message_id=None) - - if not chat_id: - return False - - sent_message = await bot.send_message( - chat_id=chat_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - return True - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ) - - lines: list[str] = [title_prompt] - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -465,24 +277,15 @@ 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=[], - form_chat_id=callback.message.chat.id, - form_message_id=callback.message.message_id, - ) + await state.update_data(questions=[]) - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") - - updated = await _edit_creation_message( - callback.bot, - state, - form_text, + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", - fallback_chat_id=callback.message.chat.id, ) - if not updated: - await _safe_delete_message(callback.message) await callback.answer() @@ -494,61 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(error_text) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", ) - if not updated: - await message.answer(form_text, parse_mode="HTML") @admin_required @@ -560,66 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) ) - if not updated: - await message.answer(error_text) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) ) - if not updated: - await message.answer(form_text, parse_mode="HTML") def _parse_reward_amount(message_text: str) -> int | None: @@ -645,44 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(error_text) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -692,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), ) - if not updated: - await message.answer(form_text, parse_mode="HTML") + await message.answer(prompt) @admin_required @@ -713,46 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(error_text) return title = data.get("title") @@ -770,54 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) - - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) 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), + parse_mode="HTML", + ) return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(error_text) return question_text = lines[0] @@ -827,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message( - message.bot, - state, - form_text, - fallback_chat_id=message.chat.id, - ) - if not updated: - await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1013,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -1035,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 4213e60e..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -23,39 +22,6 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _snapshot_users(users: Iterable[User]) -> tuple[list[SimpleNamespace], int]: - snapshots: list[SimpleNamespace] = [] - dropped = 0 - - for user in users: - if isinstance(user, dict): - user_id = user.get("id") - telegram_id = user.get("telegram_id") - language = user.get("language") - else: - user_id = getattr(user, "id", None) - telegram_id = getattr(user, "telegram_id", None) - language = getattr(user, "language", None) - - if user_id is None or telegram_id is None: - logger.warning( - "Пропускаю пользователя без id или telegram_id: %s", - getattr(user, "id", user), - ) - dropped += 1 - continue - - snapshots.append( - SimpleNamespace( - id=user_id, - telegram_id=telegram_id, - language=language or settings.DEFAULT_LANGUAGE, - ) - ) - - return snapshots, dropped - - def _build_poll_invitation_text(poll: Poll, user: User) -> str: texts = get_texts(user.language) @@ -104,22 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_records, dropped = _snapshot_users(users) - skipped += dropped - - for index, user in enumerate(user_records, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -129,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -137,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -157,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From b9d4a8f8e9ac806255b1b0e52b300326ad8f65fd Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:57:36 +0300 Subject: [PATCH 22/31] Revert "Stabilize poll creation flow and poll delivery" --- app/handlers/admin/polls.py | 480 ++++++----------------------------- app/services/poll_service.py | 43 +--- 2 files changed, 89 insertions(+), 434 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index efcc31eb..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,209 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - - try: - await bot.delete_message(chat_id=chat_id, message_id=message_id) - except TelegramBadRequest: - # Сообщение уже удалено или нельзя удалить — продолжаем без ошибок - pass - except Exception as delete_error: # pragma: no cover - defensive logging - logger.debug( - "Не удалось удалить старое сообщение создания опроса %s: %s", - message_id, - delete_error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - - if not chat_id: - return False - - try: - new_message = await bot.send_message( - chat_id=chat_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - await state.update_data( - form_chat_id=new_message.chat.id, - form_message_id=new_message.message_id, - ) - return True - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Не удалось отправить сообщение создания опроса в чат %s: %s", - chat_id, - error, - ) - return False - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -481,24 +277,15 @@ 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=[], - form_chat_id=callback.message.chat.id, - ) + await state.update_data(questions=[]) - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") - - form_message = await callback.message.answer( - form_text, + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) - await state.update_data( - form_chat_id=form_message.chat.id, - form_message_id=form_message.message_id, - ) - - await _safe_delete_message(callback.message) await callback.answer() @@ -510,50 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - - state_data = await state.get_data() + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(error_text) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -565,55 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(error_text) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -639,38 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(error_text) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -680,11 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(form_text, parse_mode="HTML") + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -696,40 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await message.answer(cancel_text, reply_markup=keyboard, parse_mode="HTML") await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(error_text) return title = data.get("title") @@ -747,48 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - state, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await message.answer( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", + ) ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(error_text) return question_text = lines[0] @@ -798,21 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, state, form_text) - if not updated: - await message.answer(status_message, parse_mode="HTML") async def _render_poll_details(poll: Poll, language: str) -> str: @@ -979,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -1001,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 fe640784..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -18,7 +17,6 @@ from app.database.models import ( TransactionType, User, ) -from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts logger = logging.getLogger(__name__) @@ -72,42 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots: list[SimpleNamespace] = [] - for raw_user in users: - try: - user_id = getattr(raw_user, "id") - telegram_id = getattr(raw_user, "telegram_id") - except AttributeError as error: # pragma: no cover - defensive logging - logger.warning("Пропускаем пользователя без обязательных полей: %s", error) - continue - - language = getattr(raw_user, "language", None) or DEFAULT_LANGUAGE - user_snapshots.append( - SimpleNamespace(id=user_id, telegram_id=telegram_id, language=language) - ) - - for index, user in enumerate(user_snapshots, start=1): - if not user.telegram_id: - failed += 1 - logger.error( - "❌ Ошибка отправки опроса %s: у пользователя %s отсутствует telegram_id", - poll_id, - user.id, - ) - continue - + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -117,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -125,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -145,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From 9b57904cf4bd11f00e22b2b44f80521d3559e476 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:58:29 +0300 Subject: [PATCH 23/31] Revert "Revert "Stabilize poll creation UI and delivery loop"" --- app/handlers/admin/polls.py | 561 +++++++++++++++++++++++++++++------ app/services/poll_service.py | 32 +- 2 files changed, 503 insertions(+), 90 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..977c6025 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,221 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + logger.warning( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.answer( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,15 +493,22 @@ 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( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), - parse_mode="HTML", + await state.update_data( + questions=[], + form_chat_id=callback.message.chat.id, + form_message_id=callback.message.message_id, ) + + state_data = await state.get_data() + form_text = _render_creation_progress(texts, state_data, "title") + updated = await _edit_creation_message(callback.bot, state_data, form_text) + if not updated: + await _send_creation_message( + callback.message, + state, + form_text, + parse_mode="HTML", + ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,6 +1062,8 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -689,7 +1084,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_value, 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..88f0a648 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,5 +1,6 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -22,8 +23,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +71,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_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_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_snapshot, user.language) 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_id, user.telegram_id, error, ) From a4e97e031782a8b453d06af2c80206c0ab6882d6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:09:51 +0300 Subject: [PATCH 24/31] Revert "Revert "Revert "Stabilize poll creation UI and delivery loop""" --- app/handlers/admin/polls.py | 563 ++++++----------------------------- app/services/poll_service.py | 32 +- 2 files changed, 91 insertions(+), 504 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 977c6025..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,221 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - logger.warning( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -async def _send_creation_message( - message: types.Message, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> types.Message: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", - message_id, - error, - ) - - sent_message = await message.answer( - text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - - return sent_message - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -493,22 +277,15 @@ 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=[], - form_chat_id=callback.message.chat.id, - form_message_id=callback.message.message_id, - ) + await state.update_data(questions=[]) - state_data = await state.get_data() - form_text = _render_creation_progress(texts, state_data, "title") - updated = await _edit_creation_message(callback.bot, state_data, form_text) - if not updated: - await _send_creation_message( - callback.message, - state, - form_text, - parse_mode="HTML", - ) + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), + parse_mode="HTML", + ) await callback.answer() @@ -520,66 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -591,71 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -681,49 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -733,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -754,51 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) return title = data.get("title") @@ -806,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -818,55 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await _send_creation_message( - message, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + ) return question_text = lines[0] @@ -876,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1062,8 +669,6 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: @@ -1084,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 88f0a648..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot @@ -23,8 +22,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, language: str) -> str: - texts = get_texts(language) +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -71,28 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots = [ - SimpleNamespace( - id=user.id, - telegram_id=user.telegram_id, - language=user.language, - ) - for user in users - ] - - for index, user in enumerate(user_snapshots, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -102,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -110,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user.language) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -130,7 +112,7 @@ async def send_poll_to_users( failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From f0d714d787b325d0777f308c5be71ab013ff5970 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:10:13 +0300 Subject: [PATCH 25/31] Improve poll creation prompts and poll delivery --- app/handlers/admin/polls.py | 558 ++++++++++++++++++++++++++++++----- app/services/poll_service.py | 52 +++- 2 files changed, 522 insertions(+), 88 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..97352ec1 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,227 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + log_method = ( + logger.debug + if "there is no text in the message to edit" in error_text + else logger.warning + ) + log_method( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.bot.send_message( + chat_id=message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +499,14 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) + await _safe_delete_message(callback.message) await state.update_data(questions=[]) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text = _render_creation_progress(texts, await state.get_data(), "title") + await _send_creation_message( + callback.message, + state, + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,12 +1062,15 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - texts = get_texts(db_user.language) + user_language = db_user.language + texts = get_texts(user_language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -689,7 +1085,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_value, user_language), parse_mode="HTML", ) await callback.answer() diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..fc3962e8 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,10 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,8 +24,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +72,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +103,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +111,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user.language) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -108,11 +127,30 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "chat not found" in error_text or "bot was blocked by the user" in error_text: + skipped += 1 + logger.info( + "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", + user.telegram_id, + poll_id, + error, + ) + else: # pragma: no cover - unexpected telegram error + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll_id, + user.telegram_id, + error, + ) + await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From 845f03e8736bf6775f1dbc7a414142f12f74827e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:18:04 +0300 Subject: [PATCH 26/31] Revert "Streamline poll creation prompts and skip unreachable chats" --- app/handlers/admin/polls.py | 560 +++++------------------------------ app/services/poll_service.py | 52 +--- 2 files changed, 89 insertions(+), 523 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 97352ec1..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,227 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - log_method = ( - logger.debug - if "there is no text in the message to edit" in error_text - else logger.warning - ) - log_method( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -async def _send_creation_message( - message: types.Message, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> types.Message: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", - message_id, - error, - ) - - sent_message = await message.bot.send_message( - chat_id=message.chat.id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - - return sent_message - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -499,14 +277,13 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) - await _safe_delete_message(callback.message) await state.update_data(questions=[]) - form_text = _render_creation_progress(texts, await state.get_data(), "title") - await _send_creation_message( - callback.message, - state, - form_text, + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) await callback.answer() @@ -520,66 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -591,71 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -681,49 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -733,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -754,51 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) return title = data.get("title") @@ -806,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -818,55 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await _send_creation_message( - message, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + ) return question_text = lines[0] @@ -876,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1062,15 +669,12 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - user_language = db_user.language - texts = get_texts(user_language) + texts = get_texts(db_user.language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -1085,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, 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 fc3962e8..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,10 +1,8 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot -from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -24,8 +22,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, language: str) -> str: - texts = get_texts(language) +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -72,28 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots = [ - SimpleNamespace( - id=user.id, - telegram_id=user.telegram_id, - language=user.language, - ) - for user in users - ] - - for index, user in enumerate(user_snapshots, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -103,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -111,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user.language) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -127,30 +108,11 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "chat not found" in error_text or "bot was blocked by the user" in error_text: - skipped += 1 - logger.info( - "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", - user.telegram_id, - poll_id, - error, - ) - else: # pragma: no cover - unexpected telegram error - failed += 1 - logger.error( - "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, - user.telegram_id, - error, - ) - await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From 270282a340a6cbd4026ad579d2371ad3a6222bd6 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:18:59 +0300 Subject: [PATCH 27/31] Gracefully handle poll message updates --- app/handlers/admin/polls.py | 558 ++++++++++++++++++++++++++++++----- app/handlers/polls.py | 62 +++- app/services/poll_service.py | 52 +++- 3 files changed, 577 insertions(+), 95 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..97352ec1 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,227 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + log_method = ( + logger.debug + if "there is no text in the message to edit" in error_text + else logger.warning + ) + log_method( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.bot.send_message( + chat_id=message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +499,14 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) + await _safe_delete_message(callback.message) await state.update_data(questions=[]) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text = _render_creation_progress(texts, await state.get_data(), "title") + await _send_creation_message( + callback.message, + state, + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,12 +1062,15 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - texts = get_texts(db_user.language) + user_language = db_user.language + texts = get_texts(user_language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -689,7 +1085,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_value, user_language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 2572b38b..94fcbcf0 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -41,6 +42,44 @@ async def _render_question_text( return "\n".join(lines) +async def _update_poll_message( + message: types.Message, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + try: + await message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + logger.debug( + "Опросное сообщение уже актуально, пропускаем обновление: %s", + error, + ) + return True + + logger.warning( + "Не удалось обновить сообщение опроса %s: %s", + message.message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.exception( + "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", + message.message_id, + error, + ) + + return False + + def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -98,11 +137,13 @@ async def handle_poll_start( db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() @@ -163,11 +204,13 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, next_question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() return @@ -185,7 +228,12 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + if not await _update_poll_message( + callback.message, + "\n\n".join(thanks_lines), + ): + await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) + return asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..fc3962e8 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,10 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,8 +24,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +72,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +103,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +111,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user.language) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -108,11 +127,30 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "chat not found" in error_text or "bot was blocked by the user" in error_text: + skipped += 1 + logger.info( + "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", + user.telegram_id, + poll_id, + error, + ) + else: # pragma: no cover - unexpected telegram error + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll_id, + user.telegram_id, + error, + ) + await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From 2fa84f6dc94f7ea1c45cdc3bcd1b86912a5108e3 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:39:20 +0300 Subject: [PATCH 28/31] Revert "Handle redundant poll message edits gracefully" --- app/handlers/admin/polls.py | 560 +++++------------------------------ app/handlers/polls.py | 62 +--- app/services/poll_service.py | 52 +--- 3 files changed, 96 insertions(+), 578 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 97352ec1..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,227 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - log_method = ( - logger.debug - if "there is no text in the message to edit" in error_text - else logger.warning - ) - log_method( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -async def _send_creation_message( - message: types.Message, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> types.Message: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", - message_id, - error, - ) - - sent_message = await message.bot.send_message( - chat_id=message.chat.id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - - return sent_message - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -499,14 +277,13 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) - await _safe_delete_message(callback.message) await state.update_data(questions=[]) - form_text = _render_creation_progress(texts, await state.get_data(), "title") - await _send_creation_message( - callback.message, - state, - form_text, + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) await callback.answer() @@ -520,66 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -591,71 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -681,49 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -733,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -754,51 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) return title = data.get("title") @@ -806,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -818,55 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await _send_creation_message( - message, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + ) return question_text = lines[0] @@ -876,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1062,15 +669,12 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - user_language = db_user.language - texts = get_texts(user_language) + texts = get_texts(db_user.language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -1085,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, user_language), + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 94fcbcf0..2572b38b 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -42,44 +41,6 @@ async def _render_question_text( return "\n".join(lines) -async def _update_poll_message( - message: types.Message, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - try: - await message.edit_text( - text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - logger.debug( - "Опросное сообщение уже актуально, пропускаем обновление: %s", - error, - ) - return True - - logger.warning( - "Не удалось обновить сообщение опроса %s: %s", - message.message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.exception( - "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", - message.message_id, - error, - ) - - return False - - def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -137,13 +98,11 @@ async def handle_poll_start( db_user.language, ) - if not await _update_poll_message( - callback.message, + await callback.message.edit_text( question_text, reply_markup=_build_options_keyboard(response.id, question), - ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) - return + parse_mode="HTML", + ) await callback.answer() @@ -204,13 +163,11 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - if not await _update_poll_message( - callback.message, + await callback.message.edit_text( question_text, reply_markup=_build_options_keyboard(response.id, next_question), - ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) - return + parse_mode="HTML", + ) await callback.answer() return @@ -228,12 +185,7 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - if not await _update_poll_message( - callback.message, - "\n\n".join(thanks_lines), - ): - await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) - return + await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) diff --git a/app/services/poll_service.py b/app/services/poll_service.py index fc3962e8..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,10 +1,8 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot -from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -24,8 +22,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, language: str) -> str: - texts = get_texts(language) +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -72,28 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots = [ - SimpleNamespace( - id=user.id, - telegram_id=user.telegram_id, - language=user.language, - ) - for user in users - ] - - for index, user in enumerate(user_snapshots, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -103,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -111,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user.language) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -127,30 +108,11 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "chat not found" in error_text or "bot was blocked by the user" in error_text: - skipped += 1 - logger.info( - "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", - user.telegram_id, - poll_id, - error, - ) - else: # pragma: no cover - unexpected telegram error - failed += 1 - logger.error( - "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, - user.telegram_id, - error, - ) - await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From 05ba26d3ac4755a429236dce1e948e3c034334d7 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:39:57 +0300 Subject: [PATCH 29/31] Avoid redundant poll edits and acknowledge answers --- app/handlers/admin/polls.py | 558 ++++++++++++++++++++++++++++++----- app/handlers/polls.py | 85 +++++- app/services/poll_service.py | 52 +++- 3 files changed, 598 insertions(+), 97 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..97352ec1 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,227 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + log_method = ( + logger.debug + if "there is no text in the message to edit" in error_text + else logger.warning + ) + log_method( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.bot.send_message( + chat_id=message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +499,14 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) + await _safe_delete_message(callback.message) await state.update_data(questions=[]) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text = _render_creation_progress(texts, await state.get_data(), "title") + await _send_creation_message( + callback.message, + state, + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,12 +1062,15 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - texts = get_texts(db_user.language) + user_language = db_user.language + texts = get_texts(user_language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -689,7 +1085,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_value, user_language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 2572b38b..c414fdf2 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -41,6 +42,63 @@ async def _render_question_text( return "\n".join(lines) +async def _update_poll_message( + message: types.Message, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + current_markup = None + if message.reply_markup is not None: + try: + current_markup = message.reply_markup.model_dump() + except AttributeError: # pragma: no cover - aiogram v3 compatibility + current_markup = message.reply_markup.to_python() + + new_markup = None + if reply_markup is not None: + try: + new_markup = reply_markup.model_dump() + except AttributeError: # pragma: no cover - aiogram v3 compatibility + new_markup = reply_markup.to_python() + + if message.text == text and current_markup == new_markup: + return True + + try: + await message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + message.text = text + message.reply_markup = reply_markup + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + logger.debug( + "Опросное сообщение уже актуально, пропускаем обновление: %s", + error, + ) + return True + + logger.warning( + "Не удалось обновить сообщение опроса %s: %s", + message.message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.exception( + "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", + message.message_id, + error, + ) + + return False + + def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -98,11 +156,13 @@ async def handle_poll_start( db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() @@ -163,12 +223,14 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, next_question), - parse_mode="HTML", - ) - await callback.answer() + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return + await callback.answer(texts.t("POLL_ANSWER_ACCEPTED", "✅ Ответ принят")) return response.completed_at = datetime.utcnow() @@ -185,11 +247,16 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + if not await _update_poll_message( + callback.message, + "\n\n".join(thanks_lines), + ): + await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) + return asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) - await callback.answer() + await callback.answer(texts.t("POLL_ANSWER_ACCEPTED", "✅ Ответ принят")) def register_handlers(dp: Dispatcher): diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..fc3962e8 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,10 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,8 +24,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +72,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +103,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +111,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user.language) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -108,11 +127,30 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "chat not found" in error_text or "bot was blocked by the user" in error_text: + skipped += 1 + logger.info( + "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", + user.telegram_id, + poll_id, + error, + ) + else: # pragma: no cover - unexpected telegram error + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll_id, + user.telegram_id, + error, + ) + await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, ) From 7ff0ed1248c231b9eed6e32df9ec5b8729dcb567 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:43:43 +0300 Subject: [PATCH 30/31] Revert "Prevent duplicate taps when answering polls" --- app/handlers/admin/polls.py | 560 +++++------------------------------ app/handlers/polls.py | 85 +----- app/services/poll_service.py | 52 +--- 3 files changed, 98 insertions(+), 599 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 97352ec1..1220cae4 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -3,8 +3,7 @@ import logging from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Optional -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -34,227 +33,6 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) -def _safe_format_price(amount_kopeks: int) -> str: - try: - return settings.format_price(amount_kopeks) - except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" - - -async def _safe_delete_message(message: types.Message) -> None: - try: - await message.delete() - except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) - - -async def _edit_creation_message( - bot: Bot, - state_data: dict, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if not chat_id or not message_id: - return False - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - return True - log_method = ( - logger.debug - if "there is no text in the message to edit" in error_text - else logger.warning - ) - log_method( - "Не удалось обновить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", - message_id, - error, - ) - return False - - -async def _send_creation_message( - message: types.Message, - state: FSMContext, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> types.Message: - state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") - - if chat_id and message_id: - try: - await message.bot.delete_message(chat_id, message_id) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) - else: - logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", - message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", - message_id, - error, - ) - - sent_message = await message.bot.send_message( - chat_id=message.chat.id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - - await state.update_data( - form_chat_id=sent_message.chat.id, - form_message_id=sent_message.message_id, - ) - - return sent_message - - -def _render_creation_progress( - texts, - data: dict, - next_step: str, - *, - status_message: str | None = None, - error_message: str | None = None, -) -> str: - lines: list[str] = ["🗳️ Создание опроса"] - - title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", - ) - lines.append("") - lines.append(title_prompt) - - title = data.get("title") - if title: - lines.append(f"• {html.escape(title)}") - - if next_step == "title": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", - ) - - lines.append("") - lines.append(description_prompt) - - if "description" in data: - description = data.get("description") - if description: - lines.append(f"• {description}") - else: - lines.append( - "• " - + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", - ) - ) - else: - lines.append("") - lines.append(get_html_help_text()) - - if next_step == "description": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", - ) - - lines.append("") - lines.append(reward_prompt) - - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") - else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) - - if next_step == "reward": - if error_message: - lines.append("") - lines.append(error_message) - return "\n".join(lines) - - question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - - lines.append("") - lines.append(question_prompt) - - questions = data.get("questions", []) - if questions: - lines.append("") - for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") - - if status_message: - lines.append("") - lines.append(status_message) - - if error_message: - lines.append("") - lines.append(error_message) - - return "\n".join(lines) - - class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -499,14 +277,13 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) - await _safe_delete_message(callback.message) await state.update_data(questions=[]) - form_text = _render_creation_progress(texts, await state.get_data(), "title") - await _send_creation_message( - callback.message, - state, - form_text, + await callback.message.edit_text( + texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "🗳️ Создание опроса\n\nВведите заголовок опроса:", + ), parse_mode="HTML", ) await callback.answer() @@ -520,66 +297,31 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): - texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - title = (message.text or "").strip() - await _safe_delete_message(message) - + title = message.text.strip() if not title: - error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", - ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", ) + + f"\n\n{get_html_help_text()}", + parse_mode="HTML", + ) @admin_required @@ -591,71 +333,36 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return description: Optional[str] - message_text = message.text or "" - await _safe_delete_message(message) - - if message_text == "/skip": + if message.text == "/skip": description = None else: - description = message_text.strip() + description = message.text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: - error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", - ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer( + texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message) + ) return await state.update_data(description=description) await state.set_state(PollCreationStates.waiting_for_reward) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", ) + ) def _parse_reward_amount(message_text: str) -> int | None: @@ -681,49 +388,18 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return - reward_kopeks = _parse_reward_amount(message.text or "") - await _safe_delete_message(message) + reward_kopeks = _parse_reward_amount(message.text) if reward_kopeks is None: - error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", - ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) return reward_enabled = reward_kopeks > 0 @@ -733,16 +409,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) + prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + await message.answer(prompt) @admin_required @@ -754,51 +430,21 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) - state_data = await state.get_data() - if message.text == "/cancel": - await _safe_delete_message(message) - cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", - ) - keyboard = get_admin_communications_submenu_keyboard(db_user.language) - updated = await _edit_creation_message( - message.bot, - state_data, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - if not updated: - await _send_creation_message( - message, - state, - cancel_text, - reply_markup=keyboard, - parse_mode="HTML", - ) await state.clear() + await message.answer( + texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), + reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + ) return if message.text == "/done": - await _safe_delete_message(message) data = await state.get_data() questions = data.get("questions", []) if not questions: - error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + await message.answer( + texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."), ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) return title = data.get("title") @@ -806,8 +452,6 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) - form_data = data.copy() - poll = await create_poll( db, title=title, @@ -818,55 +462,31 @@ async def process_poll_question( questions=questions, ) - reward_text = _format_reward_text(poll, db_user.language) - result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), - ).format( - title=html.escape(poll.title), - count=len(poll.questions), - reward=reward_text, - ) + await state.clear() - keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) - updated = await _edit_creation_message( - message.bot, - form_data, - result_text, - reply_markup=keyboard, + 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), parse_mode="HTML", ) - if not updated: - await _send_creation_message( - message, - state, - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] - await _safe_delete_message(message) + lines = [line.strip() for line in message.text.splitlines() if line.strip()] if len(lines) < 3: - error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) - updated = await _edit_creation_message(message.bot, state_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + ) return question_text = lines[0] @@ -876,26 +496,13 @@ async def process_poll_question( questions.append({"text": question_text, "options": options}) await state.update_data(questions=questions) - new_data = await state.get_data() - status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", - ).format(question=html.escape(question_text)) - - form_text = _render_creation_progress( - texts, - new_data, - "questions", - status_message=status_message, + await message.answer( + texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=question_text), + parse_mode="HTML", ) - updated = await _edit_creation_message(message.bot, new_data, form_text) - if not updated: - await _send_creation_message( - message, - state, - form_text, - parse_mode="HTML", - ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -1062,15 +669,12 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return - poll_id_value = poll.id - if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - user_language = db_user.language - texts = get_texts(user_language) + texts = get_texts(db_user.language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -1085,7 +689,7 @@ async def confirm_poll_send( await callback.message.edit_text( result_text, - reply_markup=_build_poll_details_keyboard(poll_id_value, user_language), + reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index c414fdf2..2572b38b 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -42,63 +41,6 @@ async def _render_question_text( return "\n".join(lines) -async def _update_poll_message( - message: types.Message, - text: str, - *, - reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", -) -> bool: - current_markup = None - if message.reply_markup is not None: - try: - current_markup = message.reply_markup.model_dump() - except AttributeError: # pragma: no cover - aiogram v3 compatibility - current_markup = message.reply_markup.to_python() - - new_markup = None - if reply_markup is not None: - try: - new_markup = reply_markup.model_dump() - except AttributeError: # pragma: no cover - aiogram v3 compatibility - new_markup = reply_markup.to_python() - - if message.text == text and current_markup == new_markup: - return True - - try: - await message.edit_text( - text, - reply_markup=reply_markup, - parse_mode=parse_mode, - ) - message.text = text - message.reply_markup = reply_markup - return True - except TelegramBadRequest as error: - error_text = str(error).lower() - if "message is not modified" in error_text: - logger.debug( - "Опросное сообщение уже актуально, пропускаем обновление: %s", - error, - ) - return True - - logger.warning( - "Не удалось обновить сообщение опроса %s: %s", - message.message_id, - error, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.exception( - "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", - message.message_id, - error, - ) - - return False - - def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -156,13 +98,11 @@ async def handle_poll_start( db_user.language, ) - if not await _update_poll_message( - callback.message, + await callback.message.edit_text( question_text, reply_markup=_build_options_keyboard(response.id, question), - ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) - return + parse_mode="HTML", + ) await callback.answer() @@ -223,14 +163,12 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - if not await _update_poll_message( - callback.message, + await callback.message.edit_text( question_text, reply_markup=_build_options_keyboard(response.id, next_question), - ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) - return - await callback.answer(texts.t("POLL_ANSWER_ACCEPTED", "✅ Ответ принят")) + parse_mode="HTML", + ) + await callback.answer() return response.completed_at = datetime.utcnow() @@ -247,16 +185,11 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - if not await _update_poll_message( - callback.message, - "\n\n".join(thanks_lines), - ): - await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) - return + await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) - await callback.answer(texts.t("POLL_ANSWER_ACCEPTED", "✅ Ответ принят")) + await callback.answer() def register_handlers(dp: Dispatcher): diff --git a/app/services/poll_service.py b/app/services/poll_service.py index fc3962e8..5e8b6187 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,10 +1,8 @@ import asyncio import logging -from types import SimpleNamespace from typing import Iterable from aiogram import Bot -from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -24,8 +22,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, language: str) -> str: - texts = get_texts(language) +def _build_poll_invitation_text(poll: Poll, user: User) -> str: + texts = get_texts(user.language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -72,28 +70,11 @@ async def send_poll_to_users( failed = 0 skipped = 0 - poll_id = poll.id - poll_snapshot = SimpleNamespace( - title=poll.title, - description=poll.description, - reward_enabled=poll.reward_enabled, - reward_amount_kopeks=poll.reward_amount_kopeks, - ) - - user_snapshots = [ - SimpleNamespace( - id=user.id, - telegram_id=user.telegram_id, - language=user.language, - ) - for user in users - ] - - for index, user in enumerate(user_snapshots, start=1): + for index, user in enumerate(users, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll_id, + PollResponse.poll_id == poll.id, PollResponse.user_id == user.id, ) ) @@ -103,7 +84,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll_id, + poll_id=poll.id, user_id=user.id, ) db.add(response) @@ -111,7 +92,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll_snapshot, user.language) + text = _build_poll_invitation_text(poll, user) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -127,30 +108,11 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) - except TelegramBadRequest as error: - error_text = str(error).lower() - if "chat not found" in error_text or "bot was blocked by the user" in error_text: - skipped += 1 - logger.info( - "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", - user.telegram_id, - poll_id, - error, - ) - else: # pragma: no cover - unexpected telegram error - failed += 1 - logger.error( - "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, - user.telegram_id, - error, - ) - await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll_id, + poll.id, user.telegram_id, error, ) From e5e3a9e4b5666e4065cbae53b9dd2eaf5bc7b3cf Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 07:44:20 +0300 Subject: [PATCH 31/31] Refresh poll answers after recording selections --- app/handlers/admin/polls.py | 558 ++++++++++++++++++++++++++++++----- app/handlers/polls.py | 75 ++++- app/services/poll_service.py | 52 +++- 3 files changed, 589 insertions(+), 96 deletions(-) diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 1220cae4..97352ec1 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 TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,227 @@ from app.utils.validators import get_html_help_text, validate_html_tags logger = logging.getLogger(__name__) +def _safe_format_price(amount_kopeks: int) -> str: + try: + return settings.format_price(amount_kopeks) + except Exception as error: # pragma: no cover - defensive logging + logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) + return f"{amount_kopeks / 100:.2f} ₽" + + +async def _safe_delete_message(message: types.Message) -> None: + try: + await message.delete() + except TelegramBadRequest as error: + if "message to delete not found" in str(error).lower(): + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + + +async def _edit_creation_message( + bot: Bot, + state_data: dict, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if not chat_id or not message_id: + return False + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + return True + log_method = ( + logger.debug + if "there is no text in the message to edit" in error_text + else logger.warning + ) + log_method( + "Не удалось обновить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + message_id, + error, + ) + return False + + +async def _send_creation_message( + message: types.Message, + state: FSMContext, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> types.Message: + state_data = await state.get_data() + chat_id = state_data.get("form_chat_id") + message_id = state_data.get("form_message_id") + + if chat_id and message_id: + try: + await message.bot.delete_message(chat_id, message_id) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message to delete not found" in error_text: + logger.debug("Сообщение уже удалено: %s", error) + else: + logger.warning( + "Не удалось удалить сообщение создания опроса %s: %s", + message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.error( + "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + message_id, + error, + ) + + sent_message = await message.bot.send_message( + chat_id=message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + + await state.update_data( + form_chat_id=sent_message.chat.id, + form_message_id=sent_message.message_id, + ) + + return sent_message + + +def _render_creation_progress( + texts, + data: dict, + next_step: str, + *, + status_message: str | None = None, + error_message: str | None = None, +) -> str: + lines: list[str] = ["🗳️ Создание опроса"] + + title_prompt = texts.t( + "ADMIN_POLLS_CREATION_TITLE_PROMPT", + "Введите заголовок опроса:", + ) + lines.append("") + lines.append(title_prompt) + + title = data.get("title") + if title: + lines.append(f"• {html.escape(title)}") + + if next_step == "title": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + description_prompt = texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", + "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + ) + + lines.append("") + lines.append(description_prompt) + + if "description" in data: + description = data.get("description") + if description: + lines.append(f"• {description}") + else: + lines.append( + "• " + + texts.t( + "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", + "Описание пропущено.", + ) + ) + else: + lines.append("") + lines.append(get_html_help_text()) + + if next_step == "description": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + reward_prompt = texts.t( + "ADMIN_POLLS_CREATION_REWARD_PROMPT", + "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + ) + + lines.append("") + lines.append(reward_prompt) + + if "reward_enabled" in data: + if data.get("reward_enabled"): + amount = data.get("reward_amount_kopeks", 0) + lines.append(f"• {_safe_format_price(amount)}") + else: + lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + + if next_step == "reward": + if error_message: + lines.append("") + lines.append(error_message) + return "\n".join(lines) + + question_prompt = texts.t( + "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + ( + "Введите вопрос и варианты ответов.\n" + "Каждая строка — отдельный вариант.\n" + "Первая строка — текст вопроса.\n" + "Отправьте /done, когда вопросы будут добавлены." + ), + ) + + lines.append("") + lines.append(question_prompt) + + questions = data.get("questions", []) + if questions: + lines.append("") + for idx, question in enumerate(questions, start=1): + lines.append(f"{idx}. {html.escape(question['text'])}") + for option in question["options"]: + lines.append(f" • {html.escape(option)}") + + if status_message: + lines.append("") + lines.append(status_message) + + if error_message: + lines.append("") + lines.append(error_message) + + return "\n".join(lines) + + class PollCreationStates(StatesGroup): waiting_for_title = State() waiting_for_description = State() @@ -277,13 +499,14 @@ async def start_poll_creation( texts = get_texts(db_user.language) await state.clear() await state.set_state(PollCreationStates.waiting_for_title) + await _safe_delete_message(callback.message) await state.update_data(questions=[]) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "🗳️ Создание опроса\n\nВведите заголовок опроса:", - ), + form_text = _render_creation_progress(texts, await state.get_data(), "title") + await _send_creation_message( + callback.message, + state, + form_text, parse_mode="HTML", ) await callback.answer() @@ -297,31 +520,66 @@ async def process_poll_title( state: FSMContext, db: AsyncSession, ): + texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - title = message.text.strip() + title = (message.text or "").strip() + await _safe_delete_message(message) + if not title: - await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.") + error_text = texts.t( + "ADMIN_POLLS_CREATION_TITLE_EMPTY", + "❌ Заголовок не может быть пустым. Попробуйте снова.", + ) + form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return await state.update_data(title=title) await state.set_state(PollCreationStates.waiting_for_description) - texts = get_texts(db_user.language) - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "description") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - + f"\n\n{get_html_help_text()}", - parse_mode="HTML", - ) @admin_required @@ -333,36 +591,71 @@ async def process_poll_description( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return description: Optional[str] - if message.text == "/skip": + message_text = message.text or "" + await _safe_delete_message(message) + + if message_text == "/skip": description = None else: - description = message.text.strip() + description = message_text.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) + form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) 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 чтобы отключить награду.", + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "reward") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", ) - ) def _parse_reward_amount(message_text: str) -> int | None: @@ -388,18 +681,49 @@ async def process_poll_reward( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return - reward_kopeks = _parse_reward_amount(message.text) + reward_kopeks = _parse_reward_amount(message.text or "") + await _safe_delete_message(message) if reward_kopeks is None: - await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз.")) + error_text = texts.t( + "ADMIN_POLLS_CREATION_REWARD_INVALID", + "❌ Некорректная сумма. Попробуйте ещё раз.", + ) + form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return reward_enabled = reward_kopeks > 0 @@ -409,16 +733,16 @@ async def process_poll_reward( ) await state.set_state(PollCreationStates.waiting_for_questions) - prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", - ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." - ), - ) - await message.answer(prompt) + new_data = await state.get_data() + form_text = _render_creation_progress(texts, new_data, "questions") + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) @admin_required @@ -430,21 +754,51 @@ async def process_poll_question( db: AsyncSession, ): texts = get_texts(db_user.language) + state_data = await state.get_data() + if message.text == "/cancel": - await state.clear() - await message.answer( - texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."), - reply_markup=get_admin_communications_submenu_keyboard(db_user.language), + await _safe_delete_message(message) + cancel_text = texts.t( + "ADMIN_POLLS_CREATION_CANCELLED", + "❌ Создание опроса отменено.", ) + keyboard = get_admin_communications_submenu_keyboard(db_user.language) + updated = await _edit_creation_message( + message.bot, + state_data, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + if not updated: + await _send_creation_message( + message, + state, + cancel_text, + reply_markup=keyboard, + parse_mode="HTML", + ) + await state.clear() return if message.text == "/done": + await _safe_delete_message(message) data = await state.get_data() 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", + "❌ Добавьте хотя бы один вопрос.", ) + form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return title = data.get("title") @@ -452,6 +806,8 @@ async def process_poll_question( reward_enabled = data.get("reward_enabled", False) reward_amount = data.get("reward_amount_kopeks", 0) + form_data = data.copy() + poll = await create_poll( db, title=title, @@ -462,31 +818,55 @@ 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, + result_text = texts.t( + "ADMIN_POLLS_CREATION_FINISHED", + ( + "✅ Опрос «{title}» создан!\n" + "Вопросов: {count}\n" + "{reward}" ), - reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), + ).format( + title=html.escape(poll.title), + count=len(poll.questions), + reward=reward_text, + ) + + keyboard = _build_polls_keyboard(await list_polls(db), db_user.language) + updated = await _edit_creation_message( + message.bot, + form_data, + result_text, + reply_markup=keyboard, parse_mode="HTML", ) + if not updated: + await _send_creation_message( + message, + state, + result_text, + reply_markup=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 (message.text or "").splitlines() if line.strip()] + await _safe_delete_message(message) if len(lines) < 3: - await message.answer( - texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", - ) + error_text = texts.t( + "ADMIN_POLLS_CREATION_MIN_OPTIONS", + "❌ Нужен вопрос и минимум два варианта ответа.", ) + form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + updated = await _edit_creation_message(message.bot, state_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) return question_text = lines[0] @@ -496,13 +876,26 @@ async def process_poll_question( 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), - parse_mode="HTML", + new_data = await state.get_data() + status_message = texts.t( + "ADMIN_POLLS_CREATION_ADDED_QUESTION", + "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + ).format(question=html.escape(question_text)) + + form_text = _render_creation_progress( + texts, + new_data, + "questions", + status_message=status_message, ) + updated = await _edit_creation_message(message.bot, new_data, form_text) + if not updated: + await _send_creation_message( + message, + state, + form_text, + parse_mode="HTML", + ) async def _render_poll_details(poll: Poll, language: str) -> str: @@ -669,12 +1062,15 @@ async def confirm_poll_send( await callback.answer("❌ Опрос не найден", show_alert=True) return + poll_id_value = poll.id + if target.startswith("custom_"): users = await get_custom_users(db, target.replace("custom_", "")) else: users = await get_target_users(db, target) - texts = get_texts(db_user.language) + user_language = db_user.language + texts = get_texts(user_language) await callback.message.edit_text( texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), parse_mode="HTML", @@ -689,7 +1085,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_value, user_language), parse_mode="HTML", ) await callback.answer() diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 2572b38b..de51d892 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -41,6 +42,44 @@ async def _render_question_text( return "\n".join(lines) +async def _update_poll_message( + message: types.Message, + text: str, + *, + reply_markup: types.InlineKeyboardMarkup | None = None, + parse_mode: str | None = "HTML", +) -> bool: + try: + await message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + return True + except TelegramBadRequest as error: + error_text = str(error).lower() + if "message is not modified" in error_text: + logger.debug( + "Опросное сообщение уже актуально, пропускаем обновление: %s", + error, + ) + return True + + logger.warning( + "Не удалось обновить сообщение опроса %s: %s", + message.message_id, + error, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.exception( + "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", + message.message_id, + error, + ) + + return False + + def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: buttons: list[list[types.InlineKeyboardButton]] = [] for option in sorted(question.options, key=lambda o: o.order): @@ -98,11 +137,13 @@ async def handle_poll_start( db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() @@ -152,7 +193,18 @@ async def handle_poll_answer( option_id=option.id, ) - response = await get_poll_response_by_id(db, response.id) + try: + await db.refresh(response, attribute_names=["answers"]) + except Exception as error: # pragma: no cover - defensive cache busting + logger.debug( + "Не удалось обновить локальные ответы опроса %s: %s", + response.id, + error, + ) + response = await get_poll_response_by_id(db, response.id) + if not response: + await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + return index, next_question = await get_next_question(response) if next_question: @@ -163,11 +215,13 @@ async def handle_poll_answer( len(response.poll.questions), db_user.language, ) - await callback.message.edit_text( + if not await _update_poll_message( + callback.message, question_text, reply_markup=_build_options_keyboard(response.id, next_question), - parse_mode="HTML", - ) + ): + await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + return await callback.answer() return @@ -185,7 +239,12 @@ async def handle_poll_answer( ).format(amount=settings.format_price(reward_amount)) ) - await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML") + if not await _update_poll_message( + callback.message, + "\n\n".join(thanks_lines), + ): + await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) + return asyncio.create_task( _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) ) diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 5e8b6187..fc3962e8 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,8 +1,10 @@ import asyncio import logging +from types import SimpleNamespace from typing import Iterable from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -22,8 +24,8 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) -def _build_poll_invitation_text(poll: Poll, user: User) -> str: - texts = get_texts(user.language) +def _build_poll_invitation_text(poll: Poll, language: str) -> str: + texts = get_texts(language) lines: list[str] = [f"🗳️ {poll.title}"] if poll.description: @@ -70,11 +72,28 @@ async def send_poll_to_users( failed = 0 skipped = 0 - for index, user in enumerate(users, start=1): + poll_id = poll.id + poll_snapshot = SimpleNamespace( + title=poll.title, + description=poll.description, + reward_enabled=poll.reward_enabled, + reward_amount_kopeks=poll.reward_amount_kopeks, + ) + + user_snapshots = [ + SimpleNamespace( + id=user.id, + telegram_id=user.telegram_id, + language=user.language, + ) + for user in users + ] + + for index, user in enumerate(user_snapshots, start=1): existing_response = await db.execute( select(PollResponse.id).where( and_( - PollResponse.poll_id == poll.id, + PollResponse.poll_id == poll_id, PollResponse.user_id == user.id, ) ) @@ -84,7 +103,7 @@ async def send_poll_to_users( continue response = PollResponse( - poll_id=poll.id, + poll_id=poll_id, user_id=user.id, ) db.add(response) @@ -92,7 +111,7 @@ async def send_poll_to_users( try: await db.flush() - text = _build_poll_invitation_text(poll, user) + text = _build_poll_invitation_text(poll_snapshot, user.language) keyboard = build_start_keyboard(response.id, user.language) await bot.send_message( @@ -108,11 +127,30 @@ async def send_poll_to_users( if index % 20 == 0: await asyncio.sleep(1) + except TelegramBadRequest as error: + error_text = str(error).lower() + if "chat not found" in error_text or "bot was blocked by the user" in error_text: + skipped += 1 + logger.info( + "ℹ️ Пропуск пользователя %s при отправке опроса %s: %s", + user.telegram_id, + poll_id, + error, + ) + else: # pragma: no cover - unexpected telegram error + failed += 1 + logger.error( + "❌ Ошибка отправки опроса %s пользователю %s: %s", + poll_id, + user.telegram_id, + error, + ) + await db.rollback() except Exception as error: # pragma: no cover - defensive logging failed += 1 logger.error( "❌ Ошибка отправки опроса %s пользователю %s: %s", - poll.id, + poll_id, user.telegram_id, error, )