Revert "Fix aiogram Bot usage in poll handlers"

This commit is contained in:
Egor
2025-10-23 06:03:09 +03:00
committed by GitHub
parent 28603a87e1
commit fc65b62d65
9 changed files with 5 additions and 2171 deletions

View File

@@ -17,7 +17,7 @@ from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets, polls
referral, support, server_status, common, tickets
)
from app.handlers import simple_subscription
from app.handlers.admin import (
@@ -48,7 +48,6 @@ from app.handlers.admin import (
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
polls as admin_polls,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -135,7 +134,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
support.register_handlers(dp)
server_status.register_handlers(dp)
tickets.register_handlers(dp)
polls.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
@@ -163,7 +161,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_polls.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)

View File

@@ -1061,7 +1061,7 @@ class PromoOfferLog(Base):
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
@@ -1079,130 +1079,6 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class Poll(Base):
__tablename__ = "polls"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=False)
reward_enabled = Column(Boolean, default=False, nullable=False)
reward_amount_kopeks = Column(Integer, default=0, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
questions = relationship(
"PollQuestion",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollQuestion.order",
)
runs = relationship(
"PollRun",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollRun.created_at.desc()",
)
responses = relationship(
"PollResponse",
back_populates="poll",
cascade="all, delete-orphan",
)
creator = relationship("User", backref="created_polls")
class PollQuestion(Base):
__tablename__ = "poll_questions"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
order = Column(Integer, nullable=False, default=0)
text = Column(Text, nullable=False)
poll = relationship("Poll", back_populates="questions")
options = relationship(
"PollOption",
back_populates="question",
cascade="all, delete-orphan",
order_by="PollOption.order",
)
class PollOption(Base):
__tablename__ = "poll_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False)
order = Column(Integer, nullable=False, default=0)
text = Column(String(255), nullable=False)
question = relationship("PollQuestion", back_populates="options")
class PollRun(Base):
__tablename__ = "poll_runs"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
target_type = Column(String(100), nullable=False)
status = Column(String(50), default="scheduled", nullable=False)
total_count = Column(Integer, default=0)
sent_count = Column(Integer, default=0)
failed_count = Column(Integer, default=0)
completed_count = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
poll = relationship("Poll", back_populates="runs")
creator = relationship("User", backref="created_poll_runs")
class PollResponse(Base):
__tablename__ = "poll_responses"
__table_args__ = (
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
run_id = Column(Integer, ForeignKey("poll_runs.id", ondelete="SET NULL"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
current_question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="SET NULL"), nullable=True)
message_id = Column(Integer, nullable=True)
chat_id = Column(BigInteger, nullable=True)
is_completed = Column(Boolean, default=False, nullable=False)
reward_given = Column(Boolean, default=False, nullable=False)
reward_amount_kopeks = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
poll = relationship("Poll", back_populates="responses")
run = relationship("PollRun", backref="responses")
user = relationship("User", backref="poll_responses")
current_question = relationship("PollQuestion")
answers = relationship(
"PollAnswer",
back_populates="response",
cascade="all, delete-orphan",
)
class PollAnswer(Base):
__tablename__ = "poll_answers"
id = Column(Integer, primary_key=True, index=True)
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False)
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
response = relationship("PollResponse", back_populates="answers")
question = relationship("PollQuestion")
option = relationship("PollOption")
class ServerSquad(Base):
__tablename__ = "server_squads"

File diff suppressed because it is too large Load Diff

View File

@@ -1,322 +0,0 @@
import asyncio
import html
import logging
from datetime import datetime
from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.user import add_user_balance
from app.database.models import (
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
PollRun,
User,
)
from app.localization.texts import get_texts
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
async def _get_poll_with_questions(db: AsyncSession, poll_id: int) -> Poll | None:
stmt = (
select(Poll)
.options(selectinload(Poll.questions).selectinload(PollQuestion.options))
.where(Poll.id == poll_id)
)
result = await db.execute(stmt)
return result.unique().scalar_one_or_none()
async def _get_response(
db: AsyncSession,
poll_id: int,
user_id: int,
) -> PollResponse | None:
stmt = (
select(PollResponse)
.options(selectinload(PollResponse.answers))
.where(PollResponse.poll_id == poll_id, PollResponse.user_id == user_id)
)
result = await db.execute(stmt)
return result.unique().scalar_one_or_none()
def _get_next_question(
poll: Poll,
response: PollResponse,
) -> PollQuestion | None:
questions = sorted(poll.questions, key=lambda q: q.order)
answered_ids = {answer.question_id for answer in response.answers}
for question in questions:
if question.id not in answered_ids:
return question
return None
async def _delete_message_after_delay(bot: Bot, chat_id: int, message_id: int) -> None:
await asyncio.sleep(10)
try:
await bot.delete_message(chat_id, message_id)
except TelegramBadRequest:
pass
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to delete poll message %s: %s", message_id, exc)
def _build_question_text(
poll: Poll,
question: PollQuestion,
question_index: int,
total_questions: int,
include_description: bool,
) -> str:
header = f"📋 <b>{html.escape(poll.title)}</b>\n\n"
body = ""
if include_description:
body += f"{poll.description}\n\n"
body += (
f"❓ <b>{question_index}/{total_questions}</b>\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_"))

View File

@@ -96,17 +96,11 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "📋 Опросы"),
callback_data="admin_polls"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
@@ -978,54 +972,6 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_poll_target_keyboard(poll_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"),
callback_data=f"poll_target_{poll_id}_all"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"),
callback_data=f"poll_target_{poll_id}_active"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"),
callback_data=f"poll_target_{poll_id}_trial"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"),
callback_data=f"poll_target_{poll_id}_no"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"),
callback_data=f"poll_target_{poll_id}_expiring"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"),
callback_data=f"poll_target_{poll_id}_expired"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"),
callback_data=f"poll_target_{poll_id}_active_zero"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"),
callback_data=f"poll_target_{poll_id}_trial_zero"
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_poll_{poll_id}")]
])
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)

View File

@@ -1375,85 +1375,5 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
"ADMIN_COMMUNICATIONS_POLLS": "📋 Polls",
"ADMIN_POLLS_TITLE": "📋 <b>Polls</b>",
"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": "🆕 <b>Create a poll</b>\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": "🎁 <b>Reward settings</b>",
"ADMIN_POLLS_REWARD_STATUS": "Status: <b>{status}</b>",
"ADMIN_POLLS_REWARD_AMOUNT": "Amount: <b>{amount}</b>",
"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: <b>{count}</b>",
"ADMIN_POLLS_STATS_COMPLETED": "Finished the poll: <b>{count}</b>",
"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: <b>{category}</b>\nUsers: <b>{count}</b>",
"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: <b>{count}</b>",
"ADMIN_POLLS_SENT_FAILED": "Failed deliveries: <b>{count}</b>",
"ADMIN_POLLS_SENT_TOTAL": "Total recipients: <b>{count}</b>",
"ADMIN_POLLS_STATS_TITLE": "📊 Poll statistics",
"ADMIN_POLLS_STATS_RESPONDED": "Responses received: <b>{count}</b>",
"ADMIN_POLLS_STATS_COMPLETED_LABEL": "Completed: <b>{count}</b>",
"ADMIN_POLLS_STATS_REWARD_TOTAL": "Rewards issued: <b>{amount}</b>",
"ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)",
"ADMIN_POLLS_STATS_NO_DATA": "No answers yet.",
"ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Delete",
"ADMIN_POLLS_DELETE_PROMPT": "❓ Delete the poll? This action cannot be undone.",
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
"ADMIN_POLLS_BACK_TO_LIST": "⬅️ Back to polls list",
"POLL_NOT_AVAILABLE": "Poll is not available.",
"POLL_ALREADY_PASSED": "You have already completed this poll.",
"POLL_NO_QUESTIONS": "No poll questions found.",
"POLL_OPTION_ALREADY_CHOSEN": "Answer already selected.",
"POLL_REWARD_DESCRIPTION": "Reward for completing poll '{title}'",
"POLL_REWARD_RECEIVED": "\n\n🎁 Credited to balance: {amount}",
"POLL_COMPLETED": "🙏 Thanks for completing the poll!"
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
}

View File

@@ -1375,85 +1375,5 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
"ADMIN_COMMUNICATIONS_POLLS": "📋 Опросы",
"ADMIN_POLLS_TITLE": "📋 <b>Опросы</b>",
"ADMIN_POLLS_DESCRIPTION": "Создавайте опросы и отправляйте их пользователям по выбранным категориям.",
"ADMIN_POLLS_REWARD_ENABLED": "есть награда",
"ADMIN_POLLS_REWARD_DISABLED": "без награды",
"ADMIN_POLLS_CREATE": " Создать опрос",
"ADMIN_POLLS_ENTER_TITLE": "🆕 <b>Создание опроса</b>\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": "🎁 <b>Награда за участие</b>",
"ADMIN_POLLS_REWARD_STATUS": "Статус: <b>{status}</b>",
"ADMIN_POLLS_REWARD_AMOUNT": "Сумма: <b>{amount}</b>",
"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": "Отправлено сообщений: <b>{count}</b>",
"ADMIN_POLLS_STATS_COMPLETED": "Завершили опрос: <b>{count}</b>",
"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": "Категория: <b>{category}</b>\nПользователей: <b>{count}</b>",
"ADMIN_POLLS_CONFIRMATION_HINT": "После отправки пользователи получат приглашение пройти опрос.",
"ADMIN_POLLS_NO_USERS": "Подходящих пользователей не найдено для выбранной категории.",
"ADMIN_POLLS_SENDING": "📨 Отправляем опрос...",
"ADMIN_POLLS_SENT": "✅ Отправка завершена!",
"ADMIN_POLLS_SENT_SUCCESS": "Успешно отправлено: <b>{count}</b>",
"ADMIN_POLLS_SENT_FAILED": "Ошибок доставки: <b>{count}</b>",
"ADMIN_POLLS_SENT_TOTAL": "Всего пользователей: <b>{count}</b>",
"ADMIN_POLLS_STATS_TITLE": "📊 Статистика опроса",
"ADMIN_POLLS_STATS_RESPONDED": "Ответов получено: <b>{count}</b>",
"ADMIN_POLLS_STATS_COMPLETED_LABEL": "Прошли до конца: <b>{count}</b>",
"ADMIN_POLLS_STATS_REWARD_TOTAL": "Выдано наград: <b>{amount}</b>",
"ADMIN_POLLS_STATS_OPTION": "• {text} — {count} ({percent}%)",
"ADMIN_POLLS_STATS_NO_DATA": "Ответов пока нет.",
"ADMIN_POLLS_DELETE_CONFIRM": "🗑️ Удалить",
"ADMIN_POLLS_DELETE_PROMPT": "❓ Удалить опрос? Это действие нельзя отменить.",
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
"ADMIN_POLLS_BACK_TO_LIST": "⬅️ К списку опросов",
"POLL_NOT_AVAILABLE": "Опрос недоступен.",
"POLL_ALREADY_PASSED": "Вы уже участвовали в этом опросе.",
"POLL_NO_QUESTIONS": "Вопросы опроса не найдены.",
"POLL_OPTION_ALREADY_CHOSEN": "Ответ уже выбран.",
"POLL_REWARD_DESCRIPTION": "Награда за участие в опросе '{title}'",
"POLL_REWARD_RECEIVED": "\n\n🎁 На баланс зачислено: {amount}",
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!"
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
}

View File

@@ -70,12 +70,6 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_media = State()
confirming_broadcast = State()
creating_poll_title = State()
creating_poll_description = State()
creating_poll_question_text = State()
creating_poll_question_options = State()
creating_poll_reward_amount = State()
creating_promo_group_name = State()
creating_promo_group_traffic_discount = State()
creating_promo_group_server_discount = State()

View File

@@ -1,238 +0,0 @@
"""add polls tables"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "a3f94c8b91dd"
down_revision: Union[str, None] = "8fd1e338eb45"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
POLL_TABLE = "polls"
POLL_QUESTIONS_TABLE = "poll_questions"
POLL_OPTIONS_TABLE = "poll_options"
POLL_RUNS_TABLE = "poll_runs"
POLL_RESPONSES_TABLE = "poll_responses"
POLL_ANSWERS_TABLE = "poll_answers"
def _table_exists(inspector: Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_TABLE):
op.create_table(
POLL_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column(
"reward_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_QUESTIONS_TABLE):
op.create_table(
POLL_QUESTIONS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("text", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"),
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_OPTIONS_TABLE):
op.create_table(
POLL_OPTIONS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("text", sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(
["question_id"],
[f"{POLL_QUESTIONS_TABLE}.id"],
ondelete="CASCADE",
),
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_RUNS_TABLE):
op.create_table(
POLL_RUNS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("target_type", sa.String(length=100), nullable=False),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default="scheduled",
),
sa.Column(
"total_count",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"sent_count",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"failed_count",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"completed_count",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_RESPONSES_TABLE):
op.create_table(
POLL_RESPONSES_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("run_id", sa.Integer(), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("current_question_id", sa.Integer(), nullable=True),
sa.Column("message_id", sa.Integer(), nullable=True),
sa.Column("chat_id", sa.BigInteger(), nullable=True),
sa.Column(
"is_completed",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_given",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["poll_id"], [f"{POLL_TABLE}.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["run_id"], [f"{POLL_RUNS_TABLE}.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["current_question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="SET NULL"),
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
inspector = sa.inspect(bind)
if not _table_exists(inspector, POLL_ANSWERS_TABLE):
op.create_table(
POLL_ANSWERS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("response_id", sa.Integer(), nullable=False),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column("option_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["response_id"], [f"{POLL_RESPONSES_TABLE}.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["question_id"], [f"{POLL_QUESTIONS_TABLE}.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["option_id"], [f"{POLL_OPTIONS_TABLE}.id"], ondelete="CASCADE"),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_ANSWERS_TABLE):
op.drop_table(POLL_ANSWERS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_RESPONSES_TABLE):
op.drop_table(POLL_RESPONSES_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_RUNS_TABLE):
op.drop_table(POLL_RUNS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_OPTIONS_TABLE):
op.drop_table(POLL_OPTIONS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_QUESTIONS_TABLE):
op.drop_table(POLL_QUESTIONS_TABLE)
inspector = sa.inspect(bind)
if _table_exists(inspector, POLL_TABLE):
op.drop_table(POLL_TABLE)