diff --git a/app/bot.py b/app/bot.py
index f21b7534..826d8d6e 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
- start, menu, subscription, balance, promocode,
- referral, support, server_status, common, tickets
+ start,
+ menu,
+ subscription,
+ balance,
+ promocode,
+ referral,
+ support,
+ server_status,
+ common,
+ tickets,
)
+from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
@@ -31,6 +40,7 @@ from app.handlers.admin import (
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
+ polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
@@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
+ admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
@@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
+ user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py
new file mode 100644
index 00000000..5b3b35ad
--- /dev/null
+++ b/app/database/crud/poll.py
@@ -0,0 +1,265 @@
+import logging
+from typing import Iterable, Sequence
+
+from sqlalchemy import and_, delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.database.models import (
+ Poll,
+ PollAnswer,
+ PollOption,
+ PollQuestion,
+ PollResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def create_poll(
+ db: AsyncSession,
+ *,
+ title: str,
+ description: str | None,
+ reward_enabled: bool,
+ reward_amount_kopeks: int,
+ created_by: int | None,
+ questions: Sequence[dict[str, Iterable[str]]],
+) -> Poll:
+ poll = Poll(
+ title=title,
+ description=description,
+ reward_enabled=reward_enabled,
+ reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
+ created_by=created_by,
+ )
+ db.add(poll)
+ await db.flush()
+
+ for order, question_data in enumerate(questions, start=1):
+ question_text = question_data.get("text", "").strip()
+ if not question_text:
+ continue
+
+ question = PollQuestion(
+ poll_id=poll.id,
+ text=question_text,
+ order=order,
+ )
+ db.add(question)
+ await db.flush()
+
+ for option_order, option_text in enumerate(question_data.get("options", []), start=1):
+ option_text = option_text.strip()
+ if not option_text:
+ continue
+ option = PollOption(
+ question_id=question.id,
+ text=option_text,
+ order=option_order,
+ )
+ db.add(option)
+
+ await db.commit()
+ await db.refresh(
+ poll,
+ attribute_names=["questions"],
+ )
+ return poll
+
+
+async def list_polls(db: AsyncSession) -> list[Poll]:
+ result = await db.execute(
+ select(Poll)
+ .options(
+ selectinload(Poll.questions).options(selectinload(PollQuestion.options))
+ )
+ .order_by(Poll.created_at.desc())
+ )
+ return result.scalars().all()
+
+
+async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
+ result = await db.execute(
+ select(Poll)
+ .options(
+ selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
+ selectinload(Poll.responses),
+ )
+ .where(Poll.id == poll_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
+ poll = await db.get(Poll, poll_id)
+ if not poll:
+ return False
+
+ await db.delete(poll)
+ await db.commit()
+ logger.info("🗑️ Удалён опрос %s", poll_id)
+ return True
+
+
+async def create_poll_response(
+ db: AsyncSession,
+ poll_id: int,
+ user_id: int,
+) -> PollResponse:
+ result = await db.execute(
+ select(PollResponse)
+ .where(
+ and_(
+ PollResponse.poll_id == poll_id,
+ PollResponse.user_id == user_id,
+ )
+ )
+ )
+ response = result.scalar_one_or_none()
+ if response:
+ return response
+
+ response = PollResponse(
+ poll_id=poll_id,
+ user_id=user_id,
+ )
+ db.add(response)
+ await db.commit()
+ await db.refresh(response)
+ return response
+
+
+async def get_poll_response_by_id(
+ db: AsyncSession,
+ response_id: int,
+) -> PollResponse | None:
+ result = await db.execute(
+ select(PollResponse)
+ .options(
+ selectinload(PollResponse.poll)
+ .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
+ selectinload(PollResponse.answers),
+ selectinload(PollResponse.user),
+ )
+ .where(PollResponse.id == response_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def record_poll_answer(
+ db: AsyncSession,
+ *,
+ response_id: int,
+ question_id: int,
+ option_id: int,
+) -> PollAnswer:
+ result = await db.execute(
+ select(PollAnswer)
+ .where(
+ and_(
+ PollAnswer.response_id == response_id,
+ PollAnswer.question_id == question_id,
+ )
+ )
+ )
+ answer = result.scalar_one_or_none()
+ if answer:
+ answer.option_id = option_id
+ await db.commit()
+ await db.refresh(answer)
+ return answer
+
+ answer = PollAnswer(
+ response_id=response_id,
+ question_id=question_id,
+ option_id=option_id,
+ )
+ db.add(answer)
+ await db.commit()
+ await db.refresh(answer)
+ return answer
+
+
+async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
+ await db.execute(
+ delete(PollAnswer).where(PollAnswer.response_id == response_id)
+ )
+ await db.commit()
+
+
+async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
+ totals_result = await db.execute(
+ select(
+ func.count(PollResponse.id),
+ func.count(PollResponse.completed_at),
+ func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
+ ).where(PollResponse.poll_id == poll_id)
+ )
+ total_responses, completed_responses, reward_sum = totals_result.one()
+
+ option_counts_result = await db.execute(
+ select(
+ PollQuestion.id,
+ PollQuestion.text,
+ PollQuestion.order,
+ PollOption.id,
+ PollOption.text,
+ PollOption.order,
+ func.count(PollAnswer.id),
+ )
+ .join(PollOption, PollOption.question_id == PollQuestion.id)
+ .outerjoin(
+ PollAnswer,
+ and_(
+ PollAnswer.question_id == PollQuestion.id,
+ PollAnswer.option_id == PollOption.id,
+ ),
+ )
+ .where(PollQuestion.poll_id == poll_id)
+ .group_by(
+ PollQuestion.id,
+ PollQuestion.text,
+ PollQuestion.order,
+ PollOption.id,
+ PollOption.text,
+ PollOption.order,
+ )
+ .order_by(PollQuestion.order.asc(), PollOption.order.asc())
+ )
+
+ questions_map: dict[int, dict] = {}
+ for (
+ question_id,
+ question_text,
+ question_order,
+ option_id,
+ option_text,
+ option_order,
+ answer_count,
+ ) in option_counts_result:
+ question_entry = questions_map.setdefault(
+ question_id,
+ {
+ "id": question_id,
+ "text": question_text,
+ "order": question_order,
+ "options": [],
+ },
+ )
+ question_entry["options"].append(
+ {
+ "id": option_id,
+ "text": option_text,
+ "count": answer_count,
+ }
+ )
+
+ questions = sorted(questions_map.values(), key=lambda item: item["order"])
+
+ return {
+ "total_responses": total_responses,
+ "completed_responses": completed_responses,
+ "reward_sum_kopeks": reward_sum,
+ "questions": questions,
+ }
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index e9dea8c2..67112086 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -219,7 +219,8 @@ async def add_user_balance(
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
- bot = None
+ transaction_type: TransactionType = TransactionType.DEPOSIT,
+ bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
@@ -228,12 +229,11 @@ async def add_user_balance(
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
- from app.database.models import TransactionType
-
+
await create_trans(
db=db,
user_id=user.id,
- type=TransactionType.DEPOSIT,
+ type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
)
@@ -253,9 +253,10 @@ async def add_user_balance(
async def add_user_balance_by_id(
db: AsyncSession,
- telegram_id: int,
+ telegram_id: int,
amount_kopeks: int,
- description: str = "Пополнение баланса"
+ description: str = "Пополнение баланса",
+ transaction_type: TransactionType = TransactionType.DEPOSIT,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
@@ -263,7 +264,13 @@ async def add_user_balance_by_id(
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
- return await add_user_balance(db, user, amount_kopeks, description)
+ return await add_user_balance(
+ db,
+ user,
+ amount_kopeks,
+ description,
+ transaction_type=transaction_type,
+ )
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
diff --git a/app/database/models.py b/app/database/models.py
index d296aba9..b4e9bc80 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -58,11 +58,12 @@ class SubscriptionStatus(Enum):
class TransactionType(Enum):
- DEPOSIT = "deposit"
- WITHDRAWAL = "withdrawal"
- SUBSCRIPTION_PAYMENT = "subscription_payment"
- REFUND = "refund"
- REFERRAL_REWARD = "referral_reward"
+ DEPOSIT = "deposit"
+ WITHDRAWAL = "withdrawal"
+ SUBSCRIPTION_PAYMENT = "subscription_payment"
+ REFUND = "refund"
+ REFERRAL_REWARD = "referral_reward"
+ POLL_REWARD = "poll_reward"
class PromoCodeType(Enum):
@@ -530,6 +531,7 @@ class User(Base):
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
+ poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
@@ -1061,9 +1063,9 @@ class PromoOfferLog(Base):
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
-
+
id = Column(Integer, primary_key=True, index=True)
- target_type = Column(String(100), nullable=False)
+ target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
@@ -1079,6 +1081,106 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
+
+class Poll(Base):
+ __tablename__ = "polls"
+
+ id = Column(Integer, primary_key=True, index=True)
+ title = Column(String(255), nullable=False)
+ description = Column(Text, nullable=True)
+ reward_enabled = Column(Boolean, nullable=False, default=False)
+ reward_amount_kopeks = Column(Integer, nullable=False, default=0)
+ created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ created_at = Column(DateTime, default=func.now(), nullable=False)
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
+
+ creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
+ questions = relationship(
+ "PollQuestion",
+ back_populates="poll",
+ cascade="all, delete-orphan",
+ order_by="PollQuestion.order",
+ )
+ responses = relationship(
+ "PollResponse",
+ back_populates="poll",
+ cascade="all, delete-orphan",
+ )
+
+
+class PollQuestion(Base):
+ __tablename__ = "poll_questions"
+
+ id = Column(Integer, primary_key=True, index=True)
+ poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
+ text = Column(Text, nullable=False)
+ order = Column(Integer, nullable=False, default=0)
+
+ poll = relationship("Poll", back_populates="questions")
+ options = relationship(
+ "PollOption",
+ back_populates="question",
+ cascade="all, delete-orphan",
+ order_by="PollOption.order",
+ )
+ answers = relationship("PollAnswer", back_populates="question")
+
+
+class PollOption(Base):
+ __tablename__ = "poll_options"
+
+ id = Column(Integer, primary_key=True, index=True)
+ question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
+ text = Column(Text, nullable=False)
+ order = Column(Integer, nullable=False, default=0)
+
+ question = relationship("PollQuestion", back_populates="options")
+ answers = relationship("PollAnswer", back_populates="option")
+
+
+class PollResponse(Base):
+ __tablename__ = "poll_responses"
+
+ id = Column(Integer, primary_key=True, index=True)
+ poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ sent_at = Column(DateTime, default=func.now(), nullable=False)
+ started_at = Column(DateTime, nullable=True)
+ completed_at = Column(DateTime, nullable=True)
+ reward_given = Column(Boolean, nullable=False, default=False)
+ reward_amount_kopeks = Column(Integer, nullable=False, default=0)
+
+ poll = relationship("Poll", back_populates="responses")
+ user = relationship("User", back_populates="poll_responses")
+ answers = relationship(
+ "PollAnswer",
+ back_populates="response",
+ cascade="all, delete-orphan",
+ )
+
+ __table_args__ = (
+ UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
+ )
+
+
+class PollAnswer(Base):
+ __tablename__ = "poll_answers"
+
+ id = Column(Integer, primary_key=True, index=True)
+ response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
+ question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
+ option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_at = Column(DateTime, default=func.now(), nullable=False)
+
+ response = relationship("PollResponse", back_populates="answers")
+ question = relationship("PollQuestion", back_populates="answers")
+ option = relationship("PollOption", back_populates="answers")
+
+ __table_args__ = (
+ UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
+ )
+
+
class ServerSquad(Base):
__tablename__ = "server_squads"
diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py
new file mode 100644
index 00000000..1220cae4
--- /dev/null
+++ b/app/handlers/admin/polls.py
@@ -0,0 +1,825 @@
+import html
+import logging
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+from typing import Optional
+
+from aiogram import Dispatcher, F, types
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.poll import (
+ create_poll,
+ delete_poll,
+ get_poll_by_id,
+ get_poll_statistics,
+ list_polls,
+)
+from app.database.models import Poll, User
+from app.handlers.admin.messages import (
+ get_custom_users,
+ get_custom_users_count,
+ get_target_display_name,
+ get_target_users,
+ get_target_users_count,
+)
+from app.keyboards.admin import get_admin_communications_submenu_keyboard
+from app.localization.texts import get_texts
+from app.services.poll_service import send_poll_to_users
+from app.utils.decorators import admin_required, error_handler
+from app.utils.validators import get_html_help_text, validate_html_tags
+
+logger = logging.getLogger(__name__)
+
+
+class PollCreationStates(StatesGroup):
+ waiting_for_title = State()
+ waiting_for_description = State()
+ waiting_for_reward = State()
+ waiting_for_questions = State()
+
+
+def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup:
+ texts = get_texts(language)
+ keyboard: list[list[types.InlineKeyboardButton]] = []
+
+ for poll in polls[:10]:
+ keyboard.append(
+ [
+ types.InlineKeyboardButton(
+ text=f"🗳️ {poll.title[:40]}",
+ callback_data=f"poll_view:{poll.id}",
+ )
+ ]
+ )
+
+ keyboard.append(
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"),
+ callback_data="poll_create",
+ )
+ ]
+ )
+ keyboard.append(
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="admin_submenu_communications",
+ )
+ ]
+ )
+
+ return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
+def _format_reward_text(poll: Poll, language: str) -> str:
+ texts = get_texts(language)
+ if poll.reward_enabled and poll.reward_amount_kopeks > 0:
+ return texts.t(
+ "ADMIN_POLLS_REWARD_ENABLED",
+ "Награда: {amount}",
+ ).format(amount=settings.format_price(poll.reward_amount_kopeks))
+ return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")
+
+
+def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"),
+ callback_data=f"poll_send:{poll_id}",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"),
+ callback_data=f"poll_stats:{poll_id}",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"),
+ callback_data=f"poll_delete:{poll_id}",
+ )
+ ],
+ [types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")],
+ ]
+ )
+
+
+def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"),
+ callback_data=f"poll_target:{poll_id}:all",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"),
+ callback_data=f"poll_target:{poll_id}:active",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"),
+ callback_data=f"poll_target:{poll_id}:trial",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"),
+ callback_data=f"poll_target:{poll_id}:no",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"),
+ callback_data=f"poll_target:{poll_id}:expiring",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"),
+ callback_data=f"poll_target:{poll_id}:expired",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"),
+ callback_data=f"poll_target:{poll_id}:active_zero",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"),
+ callback_data=f"poll_target:{poll_id}:trial_zero",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"),
+ callback_data=f"poll_custom_menu:{poll_id}",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"poll_view:{poll_id}",
+ )
+ ],
+ ]
+ )
+
+
+def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"),
+ callback_data=f"poll_custom_target:{poll_id}:today",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"),
+ callback_data=f"poll_custom_target:{poll_id}:week",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"),
+ callback_data=f"poll_custom_target:{poll_id}:month",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"),
+ callback_data=f"poll_custom_target:{poll_id}:active_today",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"),
+ callback_data=f"poll_custom_target:{poll_id}:inactive_week",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"),
+ callback_data=f"poll_custom_target:{poll_id}:inactive_month",
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"),
+ callback_data=f"poll_custom_target:{poll_id}:referrals",
+ ),
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"),
+ callback_data=f"poll_custom_target:{poll_id}:direct",
+ ),
+ ],
+ [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")],
+ ]
+ )
+
+
+def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) -> types.InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"),
+ callback_data=f"poll_send_confirm:{poll_id}:{target}",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"poll_send:{poll_id}",
+ )
+ ],
+ ]
+ )
+
+
+@admin_required
+@error_handler
+async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+ polls = await list_polls(db)
+ texts = get_texts(db_user.language)
+
+ lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ Опросы"), ""]
+ if not polls:
+ lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет."))
+ else:
+ for poll in polls[:10]:
+ reward = _format_reward_text(poll, db_user.language)
+ lines.append(
+ f"• {html.escape(poll.title)} — "
+ f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n"
+ f" {reward}"
+ )
+
+ await callback.message.edit_text(
+ "\n".join(lines),
+ reply_markup=_build_polls_keyboard(polls, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def start_poll_creation(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ await state.clear()
+ await state.set_state(PollCreationStates.waiting_for_title)
+ await state.update_data(questions=[])
+
+ await callback.message.edit_text(
+ texts.t(
+ "ADMIN_POLLS_CREATION_TITLE_PROMPT",
+ "🗳️ Создание опроса\n\nВведите заголовок опроса:",
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_poll_title(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ if message.text == "/cancel":
+ await state.clear()
+ await message.answer(
+ get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
+ reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
+ )
+ return
+
+ title = message.text.strip()
+ if not title:
+ await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.")
+ return
+
+ await state.update_data(title=title)
+ await state.set_state(PollCreationStates.waiting_for_description)
+
+ texts = get_texts(db_user.language)
+ await message.answer(
+ texts.t(
+ "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT",
+ "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
+ )
+ + f"\n\n{get_html_help_text()}",
+ parse_mode="HTML",
+ )
+
+
+@admin_required
+@error_handler
+async def process_poll_description(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ if message.text == "/cancel":
+ await state.clear()
+ await message.answer(
+ texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
+ reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
+ )
+ return
+
+ description: Optional[str]
+ if message.text == "/skip":
+ description = None
+ else:
+ description = message.text.strip()
+ is_valid, error_message = validate_html_tags(description)
+ if not is_valid:
+ await message.answer(
+ texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message)
+ )
+ return
+
+ await state.update_data(description=description)
+ await state.set_state(PollCreationStates.waiting_for_reward)
+
+ await message.answer(
+ texts.t(
+ "ADMIN_POLLS_CREATION_REWARD_PROMPT",
+ "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
+ )
+ )
+
+
+def _parse_reward_amount(message_text: str) -> int | None:
+ normalized = message_text.replace(" ", "").replace(",", ".")
+ try:
+ value = Decimal(normalized)
+ except InvalidOperation:
+ return None
+
+ if value < 0:
+ value = Decimal(0)
+
+ kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
+ return max(0, kopeks)
+
+
+@admin_required
+@error_handler
+async def process_poll_reward(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ if message.text == "/cancel":
+ await state.clear()
+ await message.answer(
+ texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
+ reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
+ )
+ return
+
+ reward_kopeks = _parse_reward_amount(message.text)
+ if reward_kopeks is None:
+ await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз."))
+ return
+
+ reward_enabled = reward_kopeks > 0
+ await state.update_data(
+ reward_enabled=reward_enabled,
+ reward_amount_kopeks=reward_kopeks,
+ )
+ await state.set_state(PollCreationStates.waiting_for_questions)
+
+ prompt = texts.t(
+ "ADMIN_POLLS_CREATION_QUESTION_PROMPT",
+ (
+ "Введите вопрос и варианты ответов.\n"
+ "Каждая строка — отдельный вариант.\n"
+ "Первая строка — текст вопроса.\n"
+ "Отправьте /done, когда вопросы будут добавлены."
+ ),
+ )
+ await message.answer(prompt)
+
+
+@admin_required
+@error_handler
+async def process_poll_question(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ if message.text == "/cancel":
+ await state.clear()
+ await message.answer(
+ texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
+ reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
+ )
+ return
+
+ if message.text == "/done":
+ data = await state.get_data()
+ questions = data.get("questions", [])
+ if not questions:
+ await message.answer(
+ texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."),
+ )
+ return
+
+ title = data.get("title")
+ description = data.get("description")
+ reward_enabled = data.get("reward_enabled", False)
+ reward_amount = data.get("reward_amount_kopeks", 0)
+
+ poll = await create_poll(
+ db,
+ title=title,
+ description=description,
+ reward_enabled=reward_enabled,
+ reward_amount_kopeks=reward_amount,
+ created_by=db_user.id,
+ questions=questions,
+ )
+
+ await state.clear()
+
+ reward_text = _format_reward_text(poll, db_user.language)
+ await message.answer(
+ texts.t(
+ "ADMIN_POLLS_CREATION_FINISHED",
+ "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
+ ).format(
+ title=poll.title,
+ count=len(poll.questions),
+ reward=reward_text,
+ ),
+ reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language),
+ parse_mode="HTML",
+ )
+ return
+
+ lines = [line.strip() for line in message.text.splitlines() if line.strip()]
+ if len(lines) < 3:
+ await message.answer(
+ texts.t(
+ "ADMIN_POLLS_CREATION_MIN_OPTIONS",
+ "❌ Нужен вопрос и минимум два варианта ответа.",
+ )
+ )
+ return
+
+ question_text = lines[0]
+ options = lines[1:]
+ data = await state.get_data()
+ questions = data.get("questions", [])
+ questions.append({"text": question_text, "options": options})
+ await state.update_data(questions=questions)
+
+ await message.answer(
+ texts.t(
+ "ADMIN_POLLS_CREATION_ADDED_QUESTION",
+ "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
+ ).format(question=question_text),
+ parse_mode="HTML",
+ )
+
+
+async def _render_poll_details(poll: Poll, language: str) -> str:
+ texts = get_texts(language)
+ lines = [f"🗳️ {html.escape(poll.title)}"]
+ if poll.description:
+ lines.append(poll.description)
+
+ lines.append(_format_reward_text(poll, language))
+ lines.append(
+ texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(
+ count=len(poll.questions)
+ )
+ )
+
+ if poll.questions:
+ lines.append("")
+ lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "Вопросы:"))
+ for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1):
+ lines.append(f"{idx}. {html.escape(question.text)}")
+ for option in sorted(question.options, key=lambda o: o.order):
+ lines.append(
+ texts.t("ADMIN_POLLS_OPTION_BULLET", " • {option}").format(
+ option=html.escape(option.text)
+ )
+ )
+
+ return "\n".join(lines)
+
+
+@admin_required
+@error_handler
+async def show_poll_details(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ text = await _render_poll_details(poll, db_user.language)
+ await callback.message.edit_text(
+ text,
+ reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def start_poll_send(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ await callback.message.edit_text(
+ texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"),
+ reply_markup=_build_target_keyboard(poll.id, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_custom_target_menu(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ await callback.message.edit_text(
+ get_texts(db_user.language).t(
+ "ADMIN_POLLS_CUSTOM_PROMPT",
+ "Выберите дополнительный критерий аудитории:",
+ ),
+ reply_markup=_build_custom_target_keyboard(poll_id, db_user.language),
+ )
+ await callback.answer()
+
+
+async def _show_send_confirmation(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ poll_id: int,
+ target: str,
+ user_count: int,
+):
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ audience_name = get_target_display_name(target)
+ texts = get_texts(db_user.language)
+ confirmation_text = texts.t(
+ "ADMIN_POLLS_SEND_CONFIRM",
+ "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
+ ).format(title=poll.title, audience=audience_name, count=user_count)
+
+ await callback.message.edit_text(
+ confirmation_text,
+ reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def select_poll_target(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ _, payload = callback.data.split(":", 1)
+ poll_id_str, target = payload.split(":", 1)
+ poll_id = int(poll_id_str)
+
+ user_count = await get_target_users_count(db, target)
+ await _show_send_confirmation(callback, db_user, db, poll_id, target, user_count)
+
+
+@admin_required
+@error_handler
+async def select_custom_poll_target(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ _, payload = callback.data.split(":", 1)
+ poll_id_str, criteria = payload.split(":", 1)
+ poll_id = int(poll_id_str)
+
+ user_count = await get_custom_users_count(db, criteria)
+ await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count)
+
+
+@admin_required
+@error_handler
+async def confirm_poll_send(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ _, payload = callback.data.split(":", 1)
+ poll_id_str, target = payload.split(":", 1)
+ poll_id = int(poll_id_str)
+
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ if target.startswith("custom_"):
+ users = await get_custom_users(db, target.replace("custom_", ""))
+ else:
+ users = await get_target_users(db, target)
+
+ texts = get_texts(db_user.language)
+ await callback.message.edit_text(
+ texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."),
+ parse_mode="HTML",
+ )
+
+ result = await send_poll_to_users(callback.bot, db, poll, users)
+
+ result_text = texts.t(
+ "ADMIN_POLLS_SEND_RESULT",
+ "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
+ ).format(**result)
+
+ await callback.message.edit_text(
+ result_text,
+ reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def show_poll_stats(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ stats = await get_poll_statistics(db, poll_id)
+ texts = get_texts(db_user.language)
+
+ reward_sum = settings.format_price(stats["reward_sum_kopeks"])
+ lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 Статистика опроса"), ""]
+ lines.append(
+ texts.t(
+ "ADMIN_POLLS_STATS_OVERVIEW",
+ "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
+ ).format(
+ total=stats["total_responses"],
+ completed=stats["completed_responses"],
+ reward=reward_sum,
+ )
+ )
+
+ for question in stats["questions"]:
+ lines.append("")
+ lines.append(f"{html.escape(question['text'])}")
+ for option in question["options"]:
+ lines.append(
+ texts.t(
+ "ADMIN_POLLS_STATS_OPTION_LINE",
+ "• {option}: {count}",
+ ).format(option=html.escape(option["text"]), count=option["count"])
+ )
+
+ await callback.message.edit_text(
+ "\n".join(lines),
+ reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def confirm_poll_delete(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ poll = await get_poll_by_id(db, poll_id)
+ if not poll:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ await callback.message.edit_text(
+ texts.t(
+ "ADMIN_POLLS_CONFIRM_DELETE",
+ "Вы уверены, что хотите удалить опрос «{title}»?",
+ ).format(title=poll.title),
+ reply_markup=types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"),
+ callback_data=f"poll_delete_confirm:{poll_id}",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"poll_view:{poll_id}",
+ )
+ ],
+ ]
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def delete_poll_handler(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ poll_id = int(callback.data.split(":")[1])
+ success = await delete_poll(db, poll_id)
+ texts = get_texts(db_user.language)
+
+ if success:
+ await callback.message.edit_text(
+ texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."),
+ reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language),
+ )
+ else:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ await callback.answer()
+
+
+def register_handlers(dp: Dispatcher):
+ dp.callback_query.register(show_polls_panel, F.data == "admin_polls")
+ dp.callback_query.register(start_poll_creation, F.data == "poll_create")
+ dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:"))
+ dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:"))
+ dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:"))
+ dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:"))
+ dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:"))
+ dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:"))
+ dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:"))
+ dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:"))
+ dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:"))
+
+ dp.message.register(process_poll_title, PollCreationStates.waiting_for_title)
+ dp.message.register(process_poll_description, PollCreationStates.waiting_for_description)
+ dp.message.register(process_poll_reward, PollCreationStates.waiting_for_reward)
+ dp.message.register(process_poll_question, PollCreationStates.waiting_for_questions)
diff --git a/app/handlers/polls.py b/app/handlers/polls.py
new file mode 100644
index 00000000..2572b38b
--- /dev/null
+++ b/app/handlers/polls.py
@@ -0,0 +1,197 @@
+import asyncio
+import logging
+from datetime import datetime
+
+from aiogram import Dispatcher, F, types
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.poll import (
+ get_poll_response_by_id,
+ record_poll_answer,
+)
+from app.database.models import PollQuestion, User
+from app.localization.texts import get_texts
+from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
+
+logger = logging.getLogger(__name__)
+
+
+async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
+ try:
+ await asyncio.sleep(delay)
+ await bot.delete_message(chat_id, message_id)
+ except Exception as error: # pragma: no cover - cleanup best effort
+ logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
+
+
+async def _render_question_text(
+ poll_title: str,
+ question: PollQuestion,
+ current_index: int,
+ total: int,
+ language: str,
+) -> str:
+ texts = get_texts(language)
+ header = texts.t("POLL_QUESTION_HEADER", "Вопрос {current}/{total}").format(
+ current=current_index,
+ total=total,
+ )
+ lines = [f"🗳️ {poll_title}", "", header, "", question.text]
+ return "\n".join(lines)
+
+
+def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
+ buttons: list[list[types.InlineKeyboardButton]] = []
+ for option in sorted(question.options, key=lambda o: o.order):
+ buttons.append(
+ [
+ types.InlineKeyboardButton(
+ text=option.text,
+ callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
+ )
+ ]
+ )
+ return types.InlineKeyboardMarkup(inline_keyboard=buttons)
+
+
+async def handle_poll_start(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ try:
+ response_id = int(callback.data.split(":")[1])
+ except (IndexError, ValueError):
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ response = await get_poll_response_by_id(db, response_id)
+ if not response or response.user_id != db_user.id:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+
+ if response.completed_at:
+ await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
+ return
+
+ if not response.poll or not response.poll.questions:
+ await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
+ return
+
+ if not response.started_at:
+ response.started_at = datetime.utcnow()
+ await db.commit()
+
+ index, question = await get_next_question(response)
+ if not question:
+ await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
+ return
+
+ question_text = await _render_question_text(
+ response.poll.title,
+ question,
+ index,
+ len(response.poll.questions),
+ db_user.language,
+ )
+
+ await callback.message.edit_text(
+ question_text,
+ reply_markup=_build_options_keyboard(response.id, question),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+
+async def handle_poll_answer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ try:
+ _, response_id, question_id, option_id = callback.data.split(":", 3)
+ response_id = int(response_id)
+ question_id = int(question_id)
+ option_id = int(option_id)
+ except (ValueError, IndexError):
+ await callback.answer("❌ Некорректные данные", show_alert=True)
+ return
+
+ response = await get_poll_response_by_id(db, response_id)
+ texts = get_texts(db_user.language)
+
+ if not response or response.user_id != db_user.id:
+ await callback.answer("❌ Опрос не найден", show_alert=True)
+ return
+
+ if not response.poll:
+ await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
+ return
+
+ if response.completed_at:
+ await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
+ return
+
+ question = next((q for q in response.poll.questions if q.id == question_id), None)
+ if not question:
+ await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
+ return
+
+ option = await get_question_option(question, option_id)
+ if not option:
+ await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
+ return
+
+ await record_poll_answer(
+ db,
+ response_id=response.id,
+ question_id=question.id,
+ option_id=option.id,
+ )
+
+ response = await get_poll_response_by_id(db, response.id)
+ index, next_question = await get_next_question(response)
+
+ if next_question:
+ question_text = await _render_question_text(
+ response.poll.title,
+ next_question,
+ index,
+ len(response.poll.questions),
+ db_user.language,
+ )
+ await callback.message.edit_text(
+ question_text,
+ reply_markup=_build_options_keyboard(response.id, next_question),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+ return
+
+ response.completed_at = datetime.utcnow()
+ await db.commit()
+
+ reward_amount = await reward_user_for_poll(db, response)
+
+ thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
+ if reward_amount:
+ thanks_lines.append(
+ texts.t(
+ "POLL_REWARD_GRANTED",
+ "Награда {amount} зачислена на ваш баланс.",
+ ).format(amount=settings.format_price(reward_amount))
+ )
+
+ await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML")
+ asyncio.create_task(
+ _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
+ )
+ await callback.answer()
+
+
+def register_handlers(dp: Dispatcher):
+ dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
+ dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 75e72bc0..0fad927c 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
+ callback_data="admin_polls",
+ )
+ ],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index eee72fe3..0103e8e6 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1375,5 +1375,50 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
- "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
+ "MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
+ "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
+ "ADMIN_POLLS_CREATE": "➕ Create poll",
+ "ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
+ "ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
+ "ADMIN_POLLS_SEND": "📤 Send",
+ "ADMIN_POLLS_STATS": "📊 Stats",
+ "ADMIN_POLLS_DELETE": "🗑️ Delete",
+ "ADMIN_POLLS_BACK": "⬅️ Back to list",
+ "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
+ "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
+ "ADMIN_POLLS_LIST_TITLE": "🗳️ Polls",
+ "ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
+ "ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
+ "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Create poll\n\nEnter poll title:",
+ "ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
+ "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
+ "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
+ "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
+ "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
+ "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
+ "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
+ "ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
+ "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
+ "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
+ "ADMIN_POLLS_QUESTION_LIST_HEADER": "Questions:",
+ "ADMIN_POLLS_OPTION_BULLET": " • {option}",
+ "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
+ "ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
+ "ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
+ "ADMIN_POLLS_SENDING": "📤 Sending poll...",
+ "ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
+ "ADMIN_POLLS_STATS_HEADER": "📊 Poll statistics",
+ "ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
+ "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
+ "ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
+ "ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
+ "POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
+ "POLL_INVITATION_START": "Tap the button below to answer the poll.",
+ "POLL_START_BUTTON": "📝 Take the poll",
+ "POLL_QUESTION_HEADER": "Question {current}/{total}",
+ "POLL_ALREADY_COMPLETED": "You have already completed this poll.",
+ "POLL_EMPTY": "Poll is not available yet.",
+ "POLL_ERROR": "Unable to process the poll. Please try again later.",
+ "POLL_COMPLETED": "🙏 Thanks for completing the poll!",
+ "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
}
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index ca6da8ca..20f6ed7e 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1375,5 +1375,50 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
- "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
+ "MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
+ "ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
+ "ADMIN_POLLS_CREATE": "➕ Создать опрос",
+ "ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
+ "ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
+ "ADMIN_POLLS_SEND": "📤 Отправить",
+ "ADMIN_POLLS_STATS": "📊 Статистика",
+ "ADMIN_POLLS_DELETE": "🗑️ Удалить",
+ "ADMIN_POLLS_BACK": "⬅️ К списку",
+ "ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
+ "ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
+ "ADMIN_POLLS_LIST_TITLE": "🗳️ Опросы",
+ "ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
+ "ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
+ "ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ Создание опроса\n\nВведите заголовок опроса:",
+ "ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
+ "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
+ "ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
+ "ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
+ "ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
+ "ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
+ "ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
+ "ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
+ "ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
+ "ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
+ "ADMIN_POLLS_QUESTION_LIST_HEADER": "Вопросы:",
+ "ADMIN_POLLS_OPTION_BULLET": " • {option}",
+ "ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
+ "ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
+ "ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
+ "ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
+ "ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
+ "ADMIN_POLLS_STATS_HEADER": "📊 Статистика опроса",
+ "ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
+ "ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
+ "ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
+ "ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
+ "POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
+ "POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
+ "POLL_START_BUTTON": "📝 Пройти опрос",
+ "POLL_QUESTION_HEADER": "Вопрос {current}/{total}",
+ "POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
+ "POLL_EMPTY": "Опрос пока недоступен.",
+ "POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
+ "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
+ "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
}
diff --git a/app/services/poll_service.py b/app/services/poll_service.py
new file mode 100644
index 00000000..5e8b6187
--- /dev/null
+++ b/app/services/poll_service.py
@@ -0,0 +1,179 @@
+import asyncio
+import logging
+from typing import Iterable
+
+from aiogram import Bot
+from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+from sqlalchemy import and_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.user import add_user_balance
+from app.database.models import (
+ Poll,
+ PollOption,
+ PollQuestion,
+ PollResponse,
+ TransactionType,
+ User,
+)
+from app.localization.texts import get_texts
+
+logger = logging.getLogger(__name__)
+
+
+def _build_poll_invitation_text(poll: Poll, user: User) -> str:
+ texts = get_texts(user.language)
+
+ lines: list[str] = [f"🗳️ {poll.title}"]
+ if poll.description:
+ lines.append(poll.description)
+
+ if poll.reward_enabled and poll.reward_amount_kopeks > 0:
+ reward_line = texts.t(
+ "POLL_INVITATION_REWARD",
+ "🎁 За участие вы получите {amount}.",
+ ).format(amount=settings.format_price(poll.reward_amount_kopeks))
+ lines.append(reward_line)
+
+ lines.append(
+ texts.t(
+ "POLL_INVITATION_START",
+ "Нажмите кнопку ниже, чтобы пройти опрос.",
+ )
+ )
+
+ return "\n\n".join(lines)
+
+
+def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
+ callback_data=f"poll_start:{response_id}",
+ )
+ ]
+ ]
+ )
+
+
+async def send_poll_to_users(
+ bot: Bot,
+ db: AsyncSession,
+ poll: Poll,
+ users: Iterable[User],
+) -> dict:
+ sent = 0
+ failed = 0
+ skipped = 0
+
+ for index, user in enumerate(users, start=1):
+ existing_response = await db.execute(
+ select(PollResponse.id).where(
+ and_(
+ PollResponse.poll_id == poll.id,
+ PollResponse.user_id == user.id,
+ )
+ )
+ )
+ if existing_response.scalar_one_or_none():
+ skipped += 1
+ continue
+
+ response = PollResponse(
+ poll_id=poll.id,
+ user_id=user.id,
+ )
+ db.add(response)
+
+ try:
+ await db.flush()
+
+ text = _build_poll_invitation_text(poll, user)
+ keyboard = build_start_keyboard(response.id, user.language)
+
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=text,
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+
+ await db.commit()
+ sent += 1
+
+ if index % 20 == 0:
+ await asyncio.sleep(1)
+ except Exception as error: # pragma: no cover - defensive logging
+ failed += 1
+ logger.error(
+ "❌ Ошибка отправки опроса %s пользователю %s: %s",
+ poll.id,
+ user.telegram_id,
+ error,
+ )
+ await db.rollback()
+
+ return {
+ "sent": sent,
+ "failed": failed,
+ "skipped": skipped,
+ "total": sent + failed + skipped,
+ }
+
+
+async def reward_user_for_poll(
+ db: AsyncSession,
+ response: PollResponse,
+) -> int:
+ poll = response.poll
+ if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
+ return 0
+
+ if response.reward_given:
+ return response.reward_amount_kopeks
+
+ user = response.user
+ description = f"Награда за участие в опросе \"{poll.title}\""
+
+ success = await add_user_balance(
+ db,
+ user,
+ poll.reward_amount_kopeks,
+ description,
+ transaction_type=TransactionType.POLL_REWARD,
+ )
+
+ if not success:
+ return 0
+
+ response.reward_given = True
+ response.reward_amount_kopeks = poll.reward_amount_kopeks
+ await db.commit()
+
+ return poll.reward_amount_kopeks
+
+
+async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
+ if not response.poll or not response.poll.questions:
+ return None, None
+
+ answered_question_ids = {answer.question_id for answer in response.answers}
+ ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
+
+ for index, question in enumerate(ordered_questions, start=1):
+ if question.id not in answered_question_ids:
+ return index, question
+
+ return None, None
+
+
+async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
+ for option in question.options:
+ if option.id == option_id:
+ return option
+ return None
diff --git a/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py
new file mode 100644
index 00000000..3b240735
--- /dev/null
+++ b/migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py
@@ -0,0 +1,155 @@
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "9f0f2d5a1c7b"
+down_revision: Union[str, None] = "8fd1e338eb45"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "polls",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("title", sa.String(length=255), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column(
+ "reward_enabled",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ sa.Column(
+ "reward_amount_kopeks",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ sa.Column("created_by", sa.Integer(), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
+ )
+ op.create_index("ix_polls_id", "polls", ["id"])
+
+ op.create_table(
+ "poll_questions",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("poll_id", sa.Integer(), nullable=False),
+ sa.Column("text", sa.Text(), nullable=False),
+ sa.Column(
+ "order",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
+ )
+ op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
+ op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
+
+ op.create_table(
+ "poll_options",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("question_id", sa.Integer(), nullable=False),
+ sa.Column("text", sa.Text(), nullable=False),
+ sa.Column(
+ "order",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
+ )
+ op.create_index("ix_poll_options_id", "poll_options", ["id"])
+ op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
+
+ op.create_table(
+ "poll_responses",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("poll_id", sa.Integer(), nullable=False),
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column(
+ "sent_at",
+ sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column("started_at", sa.DateTime(), nullable=True),
+ sa.Column("completed_at", sa.DateTime(), nullable=True),
+ sa.Column(
+ "reward_given",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ ),
+ sa.Column(
+ "reward_amount_kopeks",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
+ )
+ op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
+ op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
+ op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
+
+ op.create_table(
+ "poll_answers",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("response_id", sa.Integer(), nullable=False),
+ sa.Column("question_id", sa.Integer(), nullable=False),
+ sa.Column("option_id", sa.Integer(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
+ sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
+ )
+ op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
+ op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
+ op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
+ op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
+ op.drop_index("ix_poll_answers_id", table_name="poll_answers")
+ op.drop_table("poll_answers")
+
+ op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
+ op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
+ op.drop_index("ix_poll_responses_id", table_name="poll_responses")
+ op.drop_table("poll_responses")
+
+ op.drop_index("ix_poll_options_question_id", table_name="poll_options")
+ op.drop_index("ix_poll_options_id", table_name="poll_options")
+ op.drop_table("poll_options")
+
+ op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
+ op.drop_index("ix_poll_questions_id", table_name="poll_questions")
+ op.drop_table("poll_questions")
+
+ op.drop_index("ix_polls_id", table_name="polls")
+ op.drop_table("polls")