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