From ecdf2fcae49d721167a2f5624e51d44bfe61b0ed Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 23 Oct 2025 05:38:01 +0300 Subject: [PATCH] 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)