From e592b3e5c4f7ddda0ade84cd9f61730bc2048e0e Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 06:03:41 +0300 Subject: [PATCH] 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")