diff --git a/app/bot.py b/app/bot.py index da9de335..f21b7534 100644 --- a/app/bot.py +++ b/app/bot.py @@ -17,7 +17,7 @@ from app.utils.cache import cache from app.handlers import ( start, menu, subscription, balance, promocode, - referral, support, server_status, common, tickets, polls + referral, support, server_status, common, tickets ) from app.handlers import simple_subscription from app.handlers.admin import ( @@ -48,7 +48,6 @@ from app.handlers.admin import ( privacy_policy as admin_privacy_policy, public_offer as admin_public_offer, faq as admin_faq, - polls as admin_polls, ) from app.handlers.stars_payments import register_stars_handlers @@ -135,7 +134,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: support.register_handlers(dp) server_status.register_handlers(dp) tickets.register_handlers(dp) - polls.register_handlers(dp) admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) @@ -163,7 +161,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_privacy_policy.register_handlers(dp) admin_public_offer.register_handlers(dp) admin_faq.register_handlers(dp) - admin_polls.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) diff --git a/app/database/models.py b/app/database/models.py index d1283fc1..d296aba9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1061,7 +1061,7 @@ class PromoOfferLog(Base): class BroadcastHistory(Base): __tablename__ = "broadcast_history" - + id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) message_text = Column(Text, nullable=False) @@ -1079,130 +1079,6 @@ class BroadcastHistory(Base): completed_at = Column(DateTime(timezone=True), nullable=True) admin = relationship("User", back_populates="broadcasts") - -class Poll(Base): - __tablename__ = "polls" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=False) - reward_enabled = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - questions = relationship( - "PollQuestion", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollQuestion.order", - ) - runs = relationship( - "PollRun", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollRun.created_at.desc()", - ) - responses = relationship( - "PollResponse", - back_populates="poll", - cascade="all, delete-orphan", - ) - creator = relationship("User", backref="created_polls") - - -class PollQuestion(Base): - __tablename__ = "poll_questions" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(Text, nullable=False) - - poll = relationship("Poll", back_populates="questions") - options = relationship( - "PollOption", - back_populates="question", - cascade="all, delete-orphan", - order_by="PollOption.order", - ) - - -class PollOption(Base): - __tablename__ = "poll_options" - - id = Column(Integer, primary_key=True, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - order = Column(Integer, nullable=False, default=0) - text = Column(String(255), nullable=False) - - question = relationship("PollQuestion", back_populates="options") - - -class PollRun(Base): - __tablename__ = "poll_runs" - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - target_type = Column(String(100), nullable=False) - status = Column(String(50), default="scheduled", nullable=False) - total_count = Column(Integer, default=0) - sent_count = Column(Integer, default=0) - failed_count = Column(Integer, default=0) - completed_count = Column(Integer, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="runs") - creator = relationship("User", backref="created_poll_runs") - - -class PollResponse(Base): - __tablename__ = "poll_responses" - __table_args__ = ( - UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - - id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) - run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True) - message_id = Column(Integer, nullable=True) - chat_id = Column(BigInteger, nullable=True) - is_completed = Column(Boolean, default=False, nullable=False) - reward_given = Column(Boolean, default=False, nullable=False) - reward_amount_kopeks = Column(Integer, default=0, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - completed_at = Column(DateTime(timezone=True), nullable=True) - - poll = relationship("Poll", back_populates="responses") - run = relationship("PollRun", backref="responses") - user = relationship("User", backref="poll_responses") - current_question = relationship("PollQuestion") - answers = relationship( - "PollAnswer", - back_populates="response", - cascade="all, delete-orphan", - ) - - -class PollAnswer(Base): - __tablename__ = "poll_answers" - - id = Column(Integer, primary_key=True, index=True) - response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False) - option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - response = relationship("PollResponse", back_populates="answers") - question = relationship("PollQuestion") - option = relationship("PollOption") - class ServerSquad(Base): __tablename__ = "server_squads" diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py deleted file mode 100644 index d7130a54..00000000 --- a/app/handlers/admin/polls.py +++ /dev/null @@ -1,1259 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime -from decimal import Decimal, InvalidOperation -from typing import List - -from aiogram import Bot, Dispatcher, F, types -from aiogram.fsm.context import FSMContext -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.handlers.admin.messages import get_target_name, get_target_users -from app.localization.texts import get_texts -from app.states import AdminStates -from app.utils.decorators import admin_required, error_handler - -logger = logging.getLogger(__name__) - - -def _format_question_summary(index: int, question: PollQuestion) -> str: - escaped_question = html.escape(question.text) - lines = [f"{index}. {escaped_question}"] - for opt_index, option in enumerate(sorted(question.options, key=lambda o: o.order), start=1): - lines.append(f" {opt_index}) {html.escape(option.text)}") - return "\n".join(lines) - - -async def _get_poll(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options( - selectinload(Poll.questions).selectinload(PollQuestion.options), - selectinload(Poll.runs), - ) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_state_questions(data: dict) -> List[dict]: - return list(data.get("poll_questions", [])) - - -def _ensure_questions_present(questions: List[dict]) -> None: - if not questions: - raise ValueError("poll_without_questions") - - -@admin_required -@error_handler -async def show_polls_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - - stmt = ( - select(Poll) - .options(selectinload(Poll.questions)) - .order_by(Poll.created_at.desc()) - ) - result = await db.execute(stmt) - polls = result.unique().scalars().all() - - text = ( - texts.t("ADMIN_POLLS_TITLE", "📋 Опросы") - + "\n\n" - + texts.t( - "ADMIN_POLLS_DESCRIPTION", - "Создавайте опросы и отправляйте их пользователям по категориям рассылок.", - ) - ) - - keyboard: list[list[types.InlineKeyboardButton]] = [] - for poll in polls: - question_count = len(poll.questions) - reward_label = ( - texts.t("ADMIN_POLLS_REWARD_ENABLED", "🎁 награда есть") - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_DISABLED", "без награды") - ) - button_text = f"📋 {poll.title} ({question_count}) — {reward_label}" - keyboard.append( - [ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_poll_{poll.id}", - ) - ] - ) - - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), - callback_data="admin_poll_create", - ) - ] - ) - keyboard.append( - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_communications", - ) - ] - ) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def start_poll_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_title) - await state.update_data( - poll_questions=[], - reward_enabled=False, - reward_amount_kopeks=0, - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_TITLE", - "🆕 Создание опроса\n\nВведите заголовок опроса.", - ), - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_poll_title( - message: types.Message, - db_user: User, - state: FSMContext, -): - title = (message.text or "").strip() - texts = get_texts(db_user.language) - - if not title: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_TITLE_RETRY", "❗️ Укажите непустой заголовок."), - ) - return - - await state.update_data(poll_title=title) - await state.set_state(AdminStates.creating_poll_description) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_DESCRIPTION", - "✍️ Введите описание опроса. HTML-разметка поддерживается.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_description( - message: types.Message, - db_user: User, - state: FSMContext, -): - description = message.html_text or message.text or "" - description = description.strip() - texts = get_texts(db_user.language) - - if not description: - await message.answer( - texts.t("ADMIN_POLLS_ENTER_DESCRIPTION_RETRY", "❗️ Описание не может быть пустым."), - ) - return - - await state.update_data(poll_description=description) - await state.set_state(AdminStates.creating_poll_question_text) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION", - "❓ Отправьте текст первого вопроса опроса.", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_text( - message: types.Message, - db_user: User, - state: FSMContext, -): - question_text = (message.html_text or message.text or "").strip() - texts = get_texts(db_user.language) - - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_RETRY", - "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - ) - ) - return - - await state.update_data(current_question_text=question_text) - await state.set_state(AdminStates.creating_poll_question_options) - await message.answer( - texts.t( - "ADMIN_POLLS_ENTER_OPTIONS", - "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - ) - ) - - -@admin_required -@error_handler -async def process_poll_question_options( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_options = (message.text or "").splitlines() - options = [opt.strip() for opt in raw_options if opt.strip()] - - if len(options) < 2: - await message.answer( - texts.t( - "ADMIN_POLLS_NEED_MORE_OPTIONS", - "❗️ Укажите минимум два варианта ответа.", - ) - ) - return - - if len(options) > 10: - await message.answer( - texts.t( - "ADMIN_POLLS_TOO_MANY_OPTIONS", - "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - ) - ) - return - - data = await state.get_data() - question_text = data.get("current_question_text") - if not question_text: - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_NOT_FOUND", - "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - ) - ) - await state.set_state(AdminStates.creating_poll_question_text) - return - - questions = _get_state_questions(data) - questions.append({"text": question_text, "options": options}) - await state.update_data( - poll_questions=questions, - current_question_text=None, - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_QUESTION", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIGURE_REWARD", "🎁 Настроить награду"), - callback_data="admin_poll_reward_menu", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - await state.set_state(None) - await message.answer( - texts.t( - "ADMIN_POLLS_QUESTION_ADDED", - "✅ Вопрос добавлен. Выберите действие:", - ), - reply_markup=keyboard, - ) - - -@admin_required -@error_handler -async def add_another_question( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_question_text) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_ENTER_QUESTION_NEXT", - "❓ Отправьте текст следующего вопроса.", - ) - ) - await callback.answer() - - -def _build_reward_menu(texts, data: dict) -> tuple[str, types.InlineKeyboardMarkup]: - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - questions = _get_state_questions(data) - - questions_summary = "\n".join( - f"{idx}. {html.escape(q['text'])}" for idx, q in enumerate(questions, start=1) - ) or texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —") - - reward_text = ( - texts.t("ADMIN_POLLS_REWARD_ON", "Включена") - if reward_enabled and reward_amount > 0 - else texts.t("ADMIN_POLLS_REWARD_OFF", "Отключена") - ) - reward_amount_label = texts.format_price(reward_amount) - - text = ( - texts.t("ADMIN_POLLS_REWARD_TITLE", "🎁 Награда за участие") - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_STATUS", "Статус: {status}" ).format(status=reward_text) - + "\n" - + texts.t( - "ADMIN_POLLS_REWARD_AMOUNT", - "Сумма: {amount}", - ).format(amount=reward_amount_label) - + "\n\n" - + texts.t("ADMIN_POLLS_REWARD_QUESTIONS", "Всего вопросов: {count}").format(count=len(questions)) - + "\n" - + questions_summary - ) - - toggle_text = ( - texts.t("ADMIN_POLLS_REWARD_DISABLE", "🚫 Отключить награду") - if reward_enabled - else texts.t("ADMIN_POLLS_REWARD_ENABLE", "🔔 Включить награду") - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_poll_toggle_reward", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_REWARD_SET_AMOUNT", "💰 Изменить сумму"), - callback_data="admin_poll_reward_amount", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SAVE", "✅ Сохранить опрос"), - callback_data="admin_poll_save", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_ADD_MORE", "➕ Добавить ещё вопрос"), - callback_data="admin_poll_add_question", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CANCEL", "❌ Отмена"), - callback_data="admin_polls", - ) - ], - ] - ) - - return text, keyboard - - -@admin_required -@error_handler -async def show_reward_menu( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - texts = get_texts(db_user.language) - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед настройкой награды.", - ), - show_alert=True, - ) - return - - text, keyboard = _build_reward_menu(texts, data) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def toggle_reward( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - data = await state.get_data() - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - reward_enabled = not reward_enabled - if reward_enabled and reward_amount <= 0: - reward_amount = 1000 - - await state.update_data( - reward_enabled=reward_enabled, - reward_amount_kopeks=reward_amount, - ) - - texts = get_texts(db_user.language) - text, keyboard = _build_reward_menu(texts, await state.get_data()) - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - await callback.answer() - - -@admin_required -@error_handler -async def request_reward_amount( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - await state.set_state(AdminStates.creating_poll_reward_amount) - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT", - "💰 Введите сумму награды в рублях (можно с копейками).", - ) - ) - await callback.answer() - - -@admin_required -@error_handler -async def process_reward_amount( - message: types.Message, - db_user: User, - state: FSMContext, -): - texts = get_texts(db_user.language) - raw_value = (message.text or "").replace(",", ".").strip() - - try: - value_decimal = Decimal(raw_value) - except (InvalidOperation, ValueError): - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_INVALID", - "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - ) - ) - return - - if value_decimal < 0: - await message.answer( - texts.t( - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE", - "❗️ Сумма не может быть отрицательной.", - ) - ) - return - - amount_kopeks = int((value_decimal * 100).to_integral_value()) - await state.update_data( - reward_amount_kopeks=amount_kopeks, - reward_enabled=amount_kopeks > 0, - ) - await state.set_state(None) - - data = await state.get_data() - text, keyboard = _build_reward_menu(texts, data) - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") - - -@admin_required -@error_handler -async def save_poll( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession, -): - data = await state.get_data() - texts = get_texts(db_user.language) - - try: - _ensure_questions_present(_get_state_questions(data)) - except ValueError: - await callback.answer( - texts.t( - "ADMIN_POLLS_NEED_QUESTION_FIRST", - "Добавьте хотя бы один вопрос перед сохранением.", - ), - show_alert=True, - ) - return - - title = data.get("poll_title") - description = data.get("poll_description") - questions = _get_state_questions(data) - reward_enabled = bool(data.get("reward_enabled")) - reward_amount = int(data.get("reward_amount_kopeks") or 0) - - if not title or not description: - await callback.answer( - texts.t( - "ADMIN_POLLS_MISSING_DATA", - "Заполните заголовок и описание перед сохранением.", - ), - show_alert=True, - ) - return - - poll = Poll( - title=title, - description=description, - reward_enabled=reward_enabled and reward_amount > 0, - reward_amount_kopeks=reward_amount if reward_amount > 0 else 0, - created_by=db_user.id, - created_at=datetime.utcnow(), - ) - - try: - db.add(poll) - await db.flush() - - for q_index, question_data in enumerate(questions, start=1): - question = PollQuestion( - poll_id=poll.id, - text=question_data["text"], - order=q_index, - ) - db.add(question) - await db.flush() - - for opt_index, option_text in enumerate(question_data["options"], start=1): - option = PollOption( - question_id=question.id, - text=option_text, - order=opt_index, - ) - db.add(option) - - await db.commit() - await state.clear() - - poll = await _get_poll(db, poll.id) - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - summary_text = ( - texts.t("ADMIN_POLLS_CREATED", "✅ Опрос сохранён!") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions)) - + "\n" - + reward_info - + "\n\n" - + "\n".join(question_lines) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_OPEN", "📋 К опросу"), - callback_data=f"admin_poll_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - summary_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - except Exception as exc: # pragma: no cover - defensive logging - await db.rollback() - logger.exception("Failed to create poll: %s", exc) - await callback.answer( - texts.t( - "ADMIN_POLLS_SAVE_ERROR", - "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - ), - show_alert=True, - ) - - -@admin_required -@error_handler -async def show_poll_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден или был удалён."), - show_alert=True, - ) - return - - question_lines = [ - _format_question_summary(idx, question) - for idx, question in enumerate(poll.questions, start=1) - ] - - runs_total = sum(run.sent_count for run in poll.runs) - completions = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - - reward_info = ( - texts.t( - "ADMIN_POLLS_REWARD_SUMMARY", - "🎁 Награда: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - if poll.reward_enabled and poll.reward_amount_kopeks > 0 - else texts.t("ADMIN_POLLS_REWARD_SUMMARY_NONE", "🎁 Награда: не выдается") - ) - - description_preview = html.escape(poll.description) - - text = ( - f"📋 {html.escape(poll.title)}\n\n" - + texts.t("ADMIN_POLLS_DESCRIPTION_LABEL", "Описание:") - + f"\n{description_preview}\n\n" - + texts.t( - "ADMIN_POLLS_STATS_SENT", - "Отправлено сообщений: {count}", - ).format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED", - "Завершили опрос: {count}", - ).format(count=completions) - + "\n" - + reward_info - + "\n\n" - + texts.t("ADMIN_POLLS_QUESTIONS_LIST", "Вопросы:") - + "\n" - + ("\n".join(question_lines) if question_lines else texts.t("ADMIN_POLLS_NO_QUESTIONS", "— вопросы не добавлены —")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND", "🚀 Отправить"), - callback_data=f"admin_poll_send_{poll.id}", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ), - ], - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_polls", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_target_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_QUESTIONS", - "Сначала добавьте вопросы к опросу, чтобы отправлять его пользователям.", - ), - show_alert=True, - ) - return - - from app.keyboards.admin import get_poll_target_keyboard - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_SELECT_TARGET", - "🎯 Выберите категорию пользователей для отправки опроса.", - ), - reply_markup=get_poll_target_keyboard(poll.id, db_user.language), - ) - await callback.answer() - - -@admin_required -@error_handler -async def preview_poll_target( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, _, poll_id_str, target = callback.data.split("_", 3) - poll_id = int(poll_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - target_name = get_target_name(target) - - confirm_keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CONFIRM_SEND", "✅ Отправить"), - callback_data=f"admin_poll_send_confirm_{poll_id}_{target}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_send_{poll_id}", - ) - ], - ] - ) - - text = ( - texts.t("ADMIN_POLLS_CONFIRMATION_TITLE", "📨 Подтверждение отправки") - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_BODY", - "Категория: {category}\nПользователей: {count}", - ).format(category=target_name, count=len(users)) - + "\n\n" - + texts.t( - "ADMIN_POLLS_CONFIRMATION_HINT", - "После отправки пользователи получат приглашение пройти опрос.", - ) - ) - - await callback.message.edit_text( - text, - reply_markup=confirm_keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -async def _send_poll_invitation( - bot: Bot, - poll: Poll, - run: PollRun, - users: list, -) -> tuple[int, int]: - sent_count = 0 - failed_count = 0 - - invite_text = ( - f"📋 {html.escape(poll.title)}\n\n" - f"{poll.description}\n\n" - "📝 Нажмите кнопку ниже, чтобы пройти опрос." - ) - - for index, user in enumerate(users, start=1): - try: - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="📝 Пройти опрос", - callback_data=f"poll_start_{poll.id}_{run.id}", - ) - ] - ] - ) - await bot.send_message( - chat_id=user.telegram_id, - text=invite_text, - reply_markup=keyboard, - ) - sent_count += 1 - except Exception as exc: # pragma: no cover - defensive logging - failed_count += 1 - logger.warning( - "Failed to send poll %s to user %s: %s", - poll.id, - getattr(user, "telegram_id", "unknown"), - exc, - ) - if index % 25 == 0: - await asyncio.sleep(0.5) - - return sent_count, failed_count - - -@admin_required -@error_handler -async def confirm_poll_sending( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - parts = callback.data.split("_") - if len(parts) < 6: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - try: - poll_id = int(parts[4]) - except ValueError: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - target = "_".join(parts[5:]) - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - users = await get_target_users(db, target) - if not users: - await callback.answer( - texts.t( - "ADMIN_POLLS_NO_USERS", - "Подходящих пользователей не найдено для выбранной категории.", - ), - show_alert=True, - ) - return - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_SENDING", "📨 Отправляем опрос..."), - ) - - run = PollRun( - poll_id=poll.id, - target_type=target, - status="in_progress", - total_count=len(users), - created_by=db_user.id, - created_at=datetime.utcnow(), - started_at=datetime.utcnow(), - ) - db.add(run) - await db.flush() - - sent_count, failed_count = await _send_poll_invitation(callback.bot, poll, run, users) - - run.sent_count = sent_count - run.failed_count = failed_count - run.status = "completed" - run.completed_at = datetime.utcnow() - - await db.commit() - - result_text = ( - texts.t("ADMIN_POLLS_SENT", "✅ Отправка завершена!") - + "\n\n" - + texts.t("ADMIN_POLLS_SENT_SUCCESS", "Успешно отправлено: {count}").format(count=sent_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_FAILED", "Ошибок доставки: {count}").format(count=failed_count) - + "\n" - + texts.t("ADMIN_POLLS_SENT_TOTAL", "Всего пользователей: {count}").format(count=len(users)) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS_BUTTON", "📊 Статистика"), - callback_data=f"admin_poll_stats_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - result_text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def show_poll_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - total_responses = await db.scalar( - select(func.count(PollResponse.id)).where(PollResponse.poll_id == poll.id) - ) or 0 - completed_responses = await db.scalar( - select(func.count(PollResponse.id)).where( - PollResponse.poll_id == poll.id, - PollResponse.is_completed.is_(True), - ) - ) or 0 - reward_sum = await db.scalar( - select(func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0)).where( - PollResponse.poll_id == poll.id, - PollResponse.reward_given.is_(True), - ) - ) or 0 - - runs_total = await db.scalar( - select(func.coalesce(func.sum(PollRun.sent_count), 0)).where(PollRun.poll_id == poll.id) - ) or 0 - - answers_stmt = ( - select(PollAnswer.question_id, PollAnswer.option_id, func.count(PollAnswer.id)) - .join(PollResponse, PollResponse.id == PollAnswer.response_id) - .where(PollResponse.poll_id == poll.id) - .group_by(PollAnswer.question_id, PollAnswer.option_id) - ) - answers_result = await db.execute(answers_stmt) - answer_counts = { - (question_id, option_id): count - for question_id, option_id, count in answers_result.all() - } - - question_lines = [] - for question in sorted(poll.questions, key=lambda q: q.order): - total_answers_for_question = sum( - answer_counts.get((question.id, option.id), 0) - for option in question.options - ) or 0 - question_lines.append(f"{html.escape(question.text)}") - for option in sorted(question.options, key=lambda o: o.order): - option_count = answer_counts.get((question.id, option.id), 0) - percent = ( - round(option_count / total_answers_for_question * 100, 1) - if total_answers_for_question - else 0 - ) - question_lines.append( - texts.t( - "ADMIN_POLLS_STATS_OPTION", - "• {text} — {count} ({percent}%)", - ).format( - text=html.escape(option.text), - count=option_count, - percent=percent, - ) - ) - question_lines.append("") - - text = ( - texts.t("ADMIN_POLLS_STATS_TITLE", "📊 Статистика опроса") - + "\n\n" - + f"{html.escape(poll.title)}\n" - + texts.t("ADMIN_POLLS_STATS_SENT", "Сообщений отправлено: {count}").format(count=runs_total) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_RESPONDED", - "Ответов получено: {count}", - ).format(count=total_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_COMPLETED_LABEL", - "Прошли до конца: {count}", - ).format(count=completed_responses) - + "\n" - + texts.t( - "ADMIN_POLLS_STATS_REWARD_TOTAL", - "Выдано наград: {amount}", - ).format(amount=texts.format_price(reward_sum)) - + "\n\n" - + ("\n".join(question_lines).strip() or texts.t("ADMIN_POLLS_STATS_NO_DATA", "Ответов пока нет.")) - ) - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer() - - -@admin_required -@error_handler -async def ask_delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll(db, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос не найден."), - show_alert=True, - ) - return - - keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE_CONFIRM", "🗑️ Удалить"), - callback_data=f"admin_poll_delete_confirm_{poll.id}", - ) - ], - [ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_poll_{poll.id}", - ) - ], - ] - ) - - await callback.message.edit_text( - texts.t( - "ADMIN_POLLS_DELETE_PROMPT", - "❓ Удалить опрос? Это действие нельзя отменить.", - ), - reply_markup=keyboard, - ) - await callback.answer() - - -@admin_required -@error_handler -async def delete_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - poll_id = int(callback.data.split("_")[-1]) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await db.get(Poll, poll_id) - if not poll: - await callback.answer( - texts.t("ADMIN_POLLS_NOT_FOUND", "Опрос уже удалён."), - show_alert=True, - ) - return - - await db.delete(poll) - await db.commit() - - await callback.message.edit_text( - texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), - reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_BACK_TO_LIST", "⬅️ К списку опросов"), - callback_data="admin_polls", - ) - ] - ] - ), - ) - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(show_polls_menu, F.data == "admin_polls") - dp.callback_query.register(start_poll_creation, F.data == "admin_poll_create") - dp.message.register(process_poll_title, AdminStates.creating_poll_title) - dp.message.register(process_poll_description, AdminStates.creating_poll_description) - dp.message.register(process_poll_question_text, AdminStates.creating_poll_question_text) - dp.message.register(process_poll_question_options, AdminStates.creating_poll_question_options) - dp.message.register(process_reward_amount, AdminStates.creating_poll_reward_amount) - - dp.callback_query.register(add_another_question, F.data == "admin_poll_add_question") - dp.callback_query.register(show_reward_menu, F.data == "admin_poll_reward_menu") - dp.callback_query.register(toggle_reward, F.data == "admin_poll_toggle_reward") - dp.callback_query.register(request_reward_amount, F.data == "admin_poll_reward_amount") - dp.callback_query.register(save_poll, F.data == "admin_poll_save") - - dp.callback_query.register( - show_poll_details, - F.data.regexp(r"^admin_poll_(?!send_|stats_|delete_|create).+"), - ) - dp.callback_query.register( - show_poll_target_selection, - F.data.regexp(r"^admin_poll_send_\\d+$"), - ) - dp.callback_query.register(preview_poll_target, F.data.startswith("poll_target_")) - dp.callback_query.register( - confirm_poll_sending, - F.data.regexp(r"^admin_poll_send_confirm_\\d+_.+"), - ) - dp.callback_query.register( - show_poll_stats, - F.data.regexp(r"^admin_poll_stats_\\d+$"), - ) - dp.callback_query.register( - ask_delete_poll, - F.data.regexp(r"^admin_poll_delete_\\d+$"), - ) - dp.callback_query.register( - delete_poll, - F.data.regexp(r"^admin_poll_delete_confirm_\\d+$"), - ) diff --git a/app/handlers/polls.py b/app/handlers/polls.py deleted file mode 100644 index 491519ca..00000000 --- a/app/handlers/polls.py +++ /dev/null @@ -1,322 +0,0 @@ -import asyncio -import html -import logging -from datetime import datetime - -from aiogram import Bot, Dispatcher, F, types -from aiogram.exceptions import TelegramBadRequest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.database.crud.user import add_user_balance -from app.database.models import ( - Poll, - PollAnswer, - PollOption, - PollQuestion, - PollResponse, - PollRun, - User, -) -from app.localization.texts import get_texts -from app.utils.decorators import error_handler - -logger = logging.getLogger(__name__) - - -async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None: - stmt = ( - select(Poll) - .options(selectinload(Poll.questions).selectinload(PollQuestion.options)) - .where(Poll.id == poll_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -async def _get_response( - db: AsyncSession, - poll_id: int, - user_id: int, -) -> PollResponse | None: - stmt = ( - select(PollResponse) - .options(selectinload(PollResponse.answers)) - .where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id) - ) - result = await db.execute(stmt) - return result.unique().scalar_one_or_none() - - -def _get_next_question( - poll: Poll, - response: PollResponse, -) -> PollQuestion | None: - questions = sorted(poll.questions, key=lambda q: q.order) - answered_ids = {answer.question_id for answer in response.answers} - for question in questions: - if question.id not in answered_ids: - return question - return None - - -async def _delete_message_after_delay(bot: Bot, chat_id: int, message_id: int) -> None: - await asyncio.sleep(10) - try: - await bot.delete_message(chat_id, message_id) - except TelegramBadRequest: - pass - except Exception as exc: # pragma: no cover - defensive logging - logger.warning("Failed to delete poll message %s: %s", message_id, exc) - - -def _build_question_text( - poll: Poll, - question: PollQuestion, - question_index: int, - total_questions: int, - include_description: bool, -) -> str: - header = f"📋 {html.escape(poll.title)}\n\n" - body = "" - if include_description: - body += f"{poll.description}\n\n" - body += ( - f"❓ {question_index}/{total_questions}\n" - f"{html.escape(question.text)}" - ) - return header + body - - -def _build_question_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup: - buttons = [] - for option in sorted(question.options, key=lambda o: o.order): - buttons.append( - [ - types.InlineKeyboardButton( - text=option.text, - callback_data=f"poll_answer_{response_id}_{question.id}_{option.id}", - ) - ] - ) - return types.InlineKeyboardMarkup(inline_keyboard=buttons) - - -@error_handler -async def start_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, poll_id_str, run_id_str = callback.data.split("_", 2) - poll_id = int(poll_id_str) - run_id = int(run_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - poll = await _get_poll_with_questions(db, poll_id) - if not poll or not poll.questions: - await callback.answer( - texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), - show_alert=True, - ) - return - - response = await _get_response(db, poll.id, db_user.id) - if response and response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - if not response: - response = PollResponse( - poll_id=poll.id, - run_id=run_id, - user_id=db_user.id, - message_id=callback.message.message_id, - chat_id=callback.message.chat.id, - created_at=datetime.utcnow(), - ) - db.add(response) - await db.flush() - else: - response.message_id = callback.message.message_id - response.chat_id = callback.message.chat.id - await db.flush() - - next_question = _get_next_question(poll, response) - if not next_question: - await callback.answer( - texts.t("POLL_NO_QUESTIONS", "Вопросы опроса не найдены."), - show_alert=True, - ) - return - - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = len(response.answers) == 0 - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - - -@error_handler -async def answer_poll( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, -): - texts = get_texts(db_user.language) - try: - _, response_id_str, question_id_str, option_id_str = callback.data.split("_", 3) - response_id = int(response_id_str) - question_id = int(question_id_str) - option_id = int(option_id_str) - except (ValueError, IndexError): - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - response = await db.get(PollResponse, response_id) - if not response or response.user_id != db_user.id: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - if response.is_completed: - await callback.answer( - texts.t("POLL_ALREADY_PASSED", "Вы уже участвовали в этом опросе."), - show_alert=True, - ) - return - - poll = await _get_poll_with_questions(db, response.poll_id) - if not poll: - await callback.answer(texts.t("POLL_NOT_AVAILABLE", "Опрос недоступен."), show_alert=True) - return - - question = next((q for q in poll.questions if q.id == question_id), None) - if not question: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - option = next((opt for opt in question.options if opt.id == option_id), None) - if not option: - await callback.answer(texts.UNKNOWN_ERROR, show_alert=True) - return - - existing_answer = next((ans for ans in response.answers if ans.question_id == question_id), None) - if existing_answer: - await callback.answer(texts.t("POLL_OPTION_ALREADY_CHOSEN", "Ответ уже выбран.")) - return - - answer = PollAnswer( - response_id=response.id, - question_id=question.id, - option_id=option.id, - ) - db.add(answer) - await db.flush() - - response.answers.append(answer) - - next_question = _get_next_question(poll, response) - - if next_question: - response.current_question_id = next_question.id - await db.commit() - - question_index = sorted(poll.questions, key=lambda q: q.order).index(next_question) + 1 - total_questions = len(poll.questions) - include_description = False - text = _build_question_text(poll, next_question, question_index, total_questions, include_description) - keyboard = _build_question_keyboard(response.id, next_question) - - try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML") - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - await callback.answer() - return - - # Completed - response.current_question_id = None - response.is_completed = True - response.completed_at = datetime.utcnow() - - reward_text = "" - if poll.reward_enabled and poll.reward_amount_kopeks > 0 and not response.reward_given: - success = await add_user_balance( - db, - db_user, - poll.reward_amount_kopeks, - description=texts.t( - "POLL_REWARD_DESCRIPTION", - "Награда за участие в опросе '{title}'", - ).format(title=poll.title), - ) - if success: - response.reward_given = True - response.reward_amount_kopeks = poll.reward_amount_kopeks - reward_text = texts.t( - "POLL_REWARD_RECEIVED", - "\n\n🎁 На баланс зачислено: {amount}", - ).format(amount=texts.format_price(poll.reward_amount_kopeks)) - else: - logger.warning("Failed to add reward for poll %s to user %s", poll.id, db_user.telegram_id) - - if response.run_id: - run = await db.get(PollRun, response.run_id) - if run: - run.completed_count = (run.completed_count or 0) + 1 - - await db.commit() - - thank_you_text = ( - texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!") - + reward_text - ) - - try: - await callback.message.edit_text(thank_you_text) - new_message = None - except TelegramBadRequest: - new_message = await callback.message.answer(thank_you_text) - if new_message: - response.message_id = new_message.message_id - response.chat_id = new_message.chat.id - await db.commit() - - if response.chat_id and response.message_id: - asyncio.create_task( - _delete_message_after_delay(callback.bot, response.chat_id, response.message_id) - ) - - await callback.answer() - - -def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(start_poll, F.data.startswith("poll_start_")) - dp.callback_query.register(answer_poll, F.data.startswith("poll_answer_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 480dd283..75e72bc0 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -96,17 +96,11 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"), - callback_data="admin_polls" - ) - ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), @@ -978,54 +972,6 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data=f"poll_target_{poll_id}_all" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data=f"poll_target_{poll_id}_active" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data=f"poll_target_{poll_id}_trial" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data=f"poll_target_{poll_id}_no" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data=f"poll_target_{poll_id}_expiring" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data=f"poll_target_{poll_id}_expired" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data=f"poll_target_{poll_id}_active_zero" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data=f"poll_target_{poll_id}_trial_zero" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")] - ]) - - def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b9206713..eee72fe3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Polls", - "ADMIN_POLLS_TITLE": "📋 Polls", - "ADMIN_POLLS_DESCRIPTION": "Create polls and send them to users by broadcast categories.", - "ADMIN_POLLS_REWARD_ENABLED": "reward enabled", - "ADMIN_POLLS_REWARD_DISABLED": "no reward", - "ADMIN_POLLS_CREATE": "➕ Create poll", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Create a poll\n\nSend the poll title.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Title cannot be empty.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Send the poll description. HTML markup is supported.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Description cannot be empty.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Send the text of the first question.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Question text cannot be empty. Please resend it.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Send answer options, one per line (min 2, max 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Provide at least two answer options.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Maximum 10 options. Please send the list again.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Could not find question text. Start again by creating a question.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Add another question", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Configure reward", - "ADMIN_POLLS_CANCEL": "❌ Cancel", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Question saved. Choose the next action:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Send the next question text.", - "ADMIN_POLLS_NO_QUESTIONS": "— no questions yet —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Reward settings", - "ADMIN_POLLS_REWARD_STATUS": "Status: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Amount: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Total questions: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Disable reward", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Enable reward", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Change amount", - "ADMIN_POLLS_SAVE": "✅ Save poll", - "ADMIN_POLLS_ADD_MORE": "➕ Add another question", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Add at least one question before configuring the reward.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Enter reward amount in RUB (decimals allowed).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Could not parse the number. Use format 10 or 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Amount cannot be negative.", - "ADMIN_POLLS_MISSING_DATA": "Fill in title and description before saving.", - "ADMIN_POLLS_REWARD_ON": "Enabled", - "ADMIN_POLLS_REWARD_OFF": "Disabled", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Reward: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Reward: not provided", - "ADMIN_POLLS_CREATED": "✅ Poll saved!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}", - "ADMIN_POLLS_OPEN": "📋 Open poll", - "ADMIN_POLLS_SAVE_ERROR": "❌ Failed to save poll. Please try again later.", - "ADMIN_POLLS_NOT_FOUND": "Poll not found or already removed.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Description:", - "ADMIN_POLLS_STATS_SENT": "Messages sent: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Questions:", - "ADMIN_POLLS_SEND": "🚀 Send", - "ADMIN_POLLS_STATS_BUTTON": "📊 Stats", - "ADMIN_POLLS_DELETE": "🗑️ Delete", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Choose a user segment for this poll.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Send", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Send confirmation", - "ADMIN_POLLS_CONFIRMATION_BODY": "Segment: {category}\nUsers: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "Users will receive an invite to complete the poll.", - "ADMIN_POLLS_NO_USERS": "No users matched the selected category.", - "ADMIN_POLLS_SENDING": "📨 Sending the poll...", - "ADMIN_POLLS_SENT": "✅ Sending completed!", - "ADMIN_POLLS_SENT_SUCCESS": "Successfully sent: {count}", - "ADMIN_POLLS_SENT_FAILED": "Failed deliveries: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Total recipients: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics", - "ADMIN_POLLS_STATS_RESPONDED": "Responses received: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "No answers yet.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.", - "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list", - "POLL_NOT_AVAILABLE": "Poll is not available.", - "POLL_ALREADY_PASSED": "You have already completed this poll.", - "POLL_NO_QUESTIONS": "No poll questions found.", - "POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.", - "POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}", - "POLL_COMPLETED": "🙏 Thanks for completing the poll!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 53ef53cd..ca6da8ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1375,85 +1375,5 @@ "SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный", "SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный", "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически", - "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", - "ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы", - "ADMIN_POLLS_TITLE": "📋 Опросы", - "ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.", - "ADMIN_POLLS_REWARD_ENABLED": "есть награда", - "ADMIN_POLLS_REWARD_DISABLED": "без награды", - "ADMIN_POLLS_CREATE": "➕ Создать опрос", - "ADMIN_POLLS_ENTER_TITLE": "🆕 Создание опроса\n\nВведите заголовок опроса.", - "ADMIN_POLLS_ENTER_TITLE_RETRY": "❗️ Укажите непустой заголовок.", - "ADMIN_POLLS_ENTER_DESCRIPTION": "✍️ Введите описание опроса. HTML-разметка поддерживается.", - "ADMIN_POLLS_ENTER_DESCRIPTION_RETRY": "❗️ Описание не может быть пустым.", - "ADMIN_POLLS_ENTER_QUESTION": "❓ Отправьте текст первого вопроса опроса.", - "ADMIN_POLLS_ENTER_QUESTION_RETRY": "❗️ Текст вопроса не может быть пустым. Отправьте вопрос ещё раз.", - "ADMIN_POLLS_ENTER_OPTIONS": "🔢 Отправьте варианты ответов, каждый с новой строки (минимум 2, максимум 10).", - "ADMIN_POLLS_NEED_MORE_OPTIONS": "❗️ Укажите минимум два варианта ответа.", - "ADMIN_POLLS_TOO_MANY_OPTIONS": "❗️ Максимум 10 вариантов ответа. Отправьте список ещё раз.", - "ADMIN_POLLS_QUESTION_NOT_FOUND": "❌ Не удалось найти текст вопроса. Начните заново, выбрав создание вопроса.", - "ADMIN_POLLS_ADD_QUESTION": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_CONFIGURE_REWARD": "🎁 Настроить награду", - "ADMIN_POLLS_CANCEL": "❌ Отмена", - "ADMIN_POLLS_QUESTION_ADDED": "✅ Вопрос добавлен. Выберите действие:", - "ADMIN_POLLS_ENTER_QUESTION_NEXT": "❓ Отправьте текст следующего вопроса.", - "ADMIN_POLLS_NO_QUESTIONS": "— вопросы не добавлены —", - "ADMIN_POLLS_REWARD_TITLE": "🎁 Награда за участие", - "ADMIN_POLLS_REWARD_STATUS": "Статус: {status}", - "ADMIN_POLLS_REWARD_AMOUNT": "Сумма: {amount}", - "ADMIN_POLLS_REWARD_QUESTIONS": "Всего вопросов: {count}", - "ADMIN_POLLS_REWARD_DISABLE": "🚫 Отключить награду", - "ADMIN_POLLS_REWARD_ENABLE": "🔔 Включить награду", - "ADMIN_POLLS_REWARD_SET_AMOUNT": "💰 Изменить сумму", - "ADMIN_POLLS_SAVE": "✅ Сохранить опрос", - "ADMIN_POLLS_ADD_MORE": "➕ Добавить ещё вопрос", - "ADMIN_POLLS_NEED_QUESTION_FIRST": "Добавьте хотя бы один вопрос перед настройкой награды.", - "ADMIN_POLLS_REWARD_AMOUNT_PROMPT": "💰 Введите сумму награды в рублях (можно с копейками).", - "ADMIN_POLLS_REWARD_AMOUNT_INVALID": "❗️ Не удалось распознать число. Введите сумму в формате 10 или 12.5", - "ADMIN_POLLS_REWARD_AMOUNT_NEGATIVE": "❗️ Сумма не может быть отрицательной.", - "ADMIN_POLLS_MISSING_DATA": "Заполните заголовок и описание перед сохранением.", - "ADMIN_POLLS_REWARD_ON": "Включена", - "ADMIN_POLLS_REWARD_OFF": "Отключена", - "ADMIN_POLLS_REWARD_SUMMARY": "🎁 Награда: {amount}", - "ADMIN_POLLS_REWARD_SUMMARY_NONE": "🎁 Награда: не выдается", - "ADMIN_POLLS_CREATED": "✅ Опрос сохранён!", - "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}", - "ADMIN_POLLS_OPEN": "📋 К опросу", - "ADMIN_POLLS_SAVE_ERROR": "❌ Не удалось сохранить опрос. Попробуйте ещё раз позже.", - "ADMIN_POLLS_NOT_FOUND": "Опрос не найден или был удалён.", - "ADMIN_POLLS_DESCRIPTION_LABEL": "Описание:", - "ADMIN_POLLS_STATS_SENT": "Отправлено сообщений: {count}", - "ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: {count}", - "ADMIN_POLLS_QUESTIONS_LIST": "Вопросы:", - "ADMIN_POLLS_SEND": "🚀 Отправить", - "ADMIN_POLLS_STATS_BUTTON": "📊 Статистика", - "ADMIN_POLLS_DELETE": "🗑️ Удалить", - "ADMIN_POLLS_SELECT_TARGET": "🎯 Выберите категорию пользователей для отправки опроса.", - "ADMIN_POLLS_CONFIRM_SEND": "✅ Отправить", - "ADMIN_POLLS_CONFIRMATION_TITLE": "📨 Подтверждение отправки", - "ADMIN_POLLS_CONFIRMATION_BODY": "Категория: {category}\nПользователей: {count}", - "ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.", - "ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.", - "ADMIN_POLLS_SENDING": "📨 Отправляем опрос...", - "ADMIN_POLLS_SENT": "✅ Отправка завершена!", - "ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: {count}", - "ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: {count}", - "ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: {count}", - "ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса", - "ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: {count}", - "ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: {count}", - "ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: {amount}", - "ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)", - "ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.", - "ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить", - "ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.", - "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.", - "ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов", - "POLL_NOT_AVAILABLE": "Опрос недоступен.", - "POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.", - "POLL_NO_QUESTIONS": "Вопросы опроса не найдены.", - "POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.", - "POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'", - "POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}", - "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!" + "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка" } diff --git a/app/states.py b/app/states.py index b88ce142..549e314f 100644 --- a/app/states.py +++ b/app/states.py @@ -70,12 +70,6 @@ class AdminStates(StatesGroup): waiting_for_broadcast_media = State() confirming_broadcast = State() - creating_poll_title = State() - creating_poll_description = State() - creating_poll_question_text = State() - creating_poll_question_options = State() - creating_poll_reward_amount = State() - creating_promo_group_name = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() diff --git a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py b/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py deleted file mode 100644 index 31da4428..00000000 --- a/migrations/alembic/versions/a3f94c8b91dd_add_polls_tables.py +++ /dev/null @@ -1,238 +0,0 @@ -"""add polls tables""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.engine.reflection import Inspector - -revision: str = "a3f94c8b91dd" -down_revision: Union[str, None] = "8fd1e338eb45" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - -POLL_TABLE = "polls" -POLL_QUESTIONS_TABLE = "poll_questions" -POLL_OPTIONS_TABLE = "poll_options" -POLL_RUNS_TABLE = "poll_runs" -POLL_RESPONSES_TABLE = "poll_responses" -POLL_ANSWERS_TABLE = "poll_answers" - - -def _table_exists(inspector: Inspector, table_name: str) -> bool: - return table_name in inspector.get_table_names() - - -def upgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_TABLE): - op.create_table( - POLL_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("title", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=False), - sa.Column( - "reward_enabled", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.create_table( - POLL_QUESTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.Text(), nullable=False), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_OPTIONS_TABLE): - op.create_table( - POLL_OPTIONS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column( - "order", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("text", sa.String(length=255), nullable=False), - sa.ForeignKeyConstraint( - ["question_id"], - [f"{POLL_QUESTIONS_TABLE}.id"], - ondelete="CASCADE", - ), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RUNS_TABLE): - op.create_table( - POLL_RUNS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("target_type", sa.String(length=100), nullable=False), - sa.Column( - "status", - sa.String(length=50), - nullable=False, - server_default="scheduled", - ), - sa.Column( - "total_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "sent_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "failed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "completed_count", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column("created_by", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_RESPONSES_TABLE): - op.create_table( - POLL_RESPONSES_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("poll_id", sa.Integer(), nullable=False), - sa.Column("run_id", sa.Integer(), nullable=True), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("current_question_id", sa.Integer(), nullable=True), - sa.Column("message_id", sa.Integer(), nullable=True), - sa.Column("chat_id", sa.BigInteger(), nullable=True), - sa.Column( - "is_completed", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_given", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "reward_amount_kopeks", - sa.Integer(), - nullable=False, - server_default="0", - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"), - sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) - inspector = sa.inspect(bind) - - if not _table_exists(inspector, POLL_ANSWERS_TABLE): - op.create_table( - POLL_ANSWERS_TABLE, - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("response_id", sa.Integer(), nullable=False), - sa.Column("question_id", sa.Integer(), nullable=False), - sa.Column("option_id", sa.Integer(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"), - ) - - -def downgrade() -> None: - bind = op.get_bind() - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_ANSWERS_TABLE): - op.drop_table(POLL_ANSWERS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RESPONSES_TABLE): - op.drop_table(POLL_RESPONSES_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_RUNS_TABLE): - op.drop_table(POLL_RUNS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_OPTIONS_TABLE): - op.drop_table(POLL_OPTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_QUESTIONS_TABLE): - op.drop_table(POLL_QUESTIONS_TABLE) - inspector = sa.inspect(bind) - - if _table_exists(inspector, POLL_TABLE): - op.drop_table(POLL_TABLE)