Revert "Revert "Add poll management and delivery system""

This commit is contained in:
Egor
2025-10-23 06:03:41 +03:00
committed by GitHub
parent 2da12354a1
commit e592b3e5c4
11 changed files with 1856 additions and 18 deletions

View File

@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
start,
menu,
subscription,
balance,
promocode,
referral,
support,
server_status,
common,
tickets,
)
from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
@@ -31,6 +40,7 @@ from app.handlers.admin import (
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
@@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
@@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")

265
app/database/crud/poll.py Normal file
View File

@@ -0,0 +1,265 @@
import logging
from typing import Iterable, Sequence
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
)
logger = logging.getLogger(__name__)
async def create_poll(
db: AsyncSession,
*,
title: str,
description: str | None,
reward_enabled: bool,
reward_amount_kopeks: int,
created_by: int | None,
questions: Sequence[dict[str, Iterable[str]]],
) -> Poll:
poll = Poll(
title=title,
description=description,
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
created_by=created_by,
)
db.add(poll)
await db.flush()
for order, question_data in enumerate(questions, start=1):
question_text = question_data.get("text", "").strip()
if not question_text:
continue
question = PollQuestion(
poll_id=poll.id,
text=question_text,
order=order,
)
db.add(question)
await db.flush()
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
option_text = option_text.strip()
if not option_text:
continue
option = PollOption(
question_id=question.id,
text=option_text,
order=option_order,
)
db.add(option)
await db.commit()
await db.refresh(
poll,
attribute_names=["questions"],
)
return poll
async def list_polls(db: AsyncSession) -> list[Poll]:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
)
.order_by(Poll.created_at.desc())
)
return result.scalars().all()
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
selectinload(Poll.responses),
)
.where(Poll.id == poll_id)
)
return result.scalar_one_or_none()
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
poll = await db.get(Poll, poll_id)
if not poll:
return False
await db.delete(poll)
await db.commit()
logger.info("🗑️ Удалён опрос %s", poll_id)
return True
async def create_poll_response(
db: AsyncSession,
poll_id: int,
user_id: int,
) -> PollResponse:
result = await db.execute(
select(PollResponse)
.where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_id,
)
)
)
response = result.scalar_one_or_none()
if response:
return response
response = PollResponse(
poll_id=poll_id,
user_id=user_id,
)
db.add(response)
await db.commit()
await db.refresh(response)
return response
async def get_poll_response_by_id(
db: AsyncSession,
response_id: int,
) -> PollResponse | None:
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
selectinload(PollResponse.answers),
selectinload(PollResponse.user),
)
.where(PollResponse.id == response_id)
)
return result.scalar_one_or_none()
async def record_poll_answer(
db: AsyncSession,
*,
response_id: int,
question_id: int,
option_id: int,
) -> PollAnswer:
result = await db.execute(
select(PollAnswer)
.where(
and_(
PollAnswer.response_id == response_id,
PollAnswer.question_id == question_id,
)
)
)
answer = result.scalar_one_or_none()
if answer:
answer.option_id = option_id
await db.commit()
await db.refresh(answer)
return answer
answer = PollAnswer(
response_id=response_id,
question_id=question_id,
option_id=option_id,
)
db.add(answer)
await db.commit()
await db.refresh(answer)
return answer
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
await db.execute(
delete(PollAnswer).where(PollAnswer.response_id == response_id)
)
await db.commit()
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
totals_result = await db.execute(
select(
func.count(PollResponse.id),
func.count(PollResponse.completed_at),
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
).where(PollResponse.poll_id == poll_id)
)
total_responses, completed_responses, reward_sum = totals_result.one()
option_counts_result = await db.execute(
select(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
func.count(PollAnswer.id),
)
.join(PollOption, PollOption.question_id == PollQuestion.id)
.outerjoin(
PollAnswer,
and_(
PollAnswer.question_id == PollQuestion.id,
PollAnswer.option_id == PollOption.id,
),
)
.where(PollQuestion.poll_id == poll_id)
.group_by(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
)
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
)
questions_map: dict[int, dict] = {}
for (
question_id,
question_text,
question_order,
option_id,
option_text,
option_order,
answer_count,
) in option_counts_result:
question_entry = questions_map.setdefault(
question_id,
{
"id": question_id,
"text": question_text,
"order": question_order,
"options": [],
},
)
question_entry["options"].append(
{
"id": option_id,
"text": option_text,
"count": answer_count,
}
)
questions = sorted(questions_map.values(), key=lambda item: item["order"])
return {
"total_responses": total_responses,
"completed_responses": completed_responses,
"reward_sum_kopeks": reward_sum,
"questions": questions,
}

View File

@@ -219,7 +219,8 @@ async def add_user_balance(
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
@@ -228,12 +229,11 @@ async def add_user_balance(
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
)
@@ -253,9 +253,10 @@ async def add_user_balance(
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
@@ -263,7 +264,13 @@ async def add_user_balance_by_id(
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
)
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")

View File

@@ -58,11 +58,12 @@ class SubscriptionStatus(Enum):
class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
POLL_REWARD = "poll_reward"
class PromoCodeType(Enum):
@@ -530,6 +531,7 @@ class User(Base):
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
@@ -1061,9 +1063,9 @@ class PromoOfferLog(Base):
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
@@ -1079,6 +1081,106 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class Poll(Base):
__tablename__ = "polls"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
reward_enabled = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
questions = relationship(
"PollQuestion",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollQuestion.order",
)
responses = relationship(
"PollResponse",
back_populates="poll",
cascade="all, delete-orphan",
)
class PollQuestion(Base):
__tablename__ = "poll_questions"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="questions")
options = relationship(
"PollOption",
back_populates="question",
cascade="all, delete-orphan",
order_by="PollOption.order",
)
answers = relationship("PollAnswer", back_populates="question")
class PollOption(Base):
__tablename__ = "poll_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
question = relationship("PollQuestion", back_populates="options")
answers = relationship("PollAnswer", back_populates="option")
class PollResponse(Base):
__tablename__ = "poll_responses"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
sent_at = Column(DateTime, default=func.now(), nullable=False)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
reward_given = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="responses")
user = relationship("User", back_populates="poll_responses")
answers = relationship(
"PollAnswer",
back_populates="response",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
class PollAnswer(Base):
__tablename__ = "poll_answers"
id = Column(Integer, primary_key=True, index=True)
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
response = relationship("PollResponse", back_populates="answers")
question = relationship("PollQuestion", back_populates="answers")
option = relationship("PollOption", back_populates="answers")
__table_args__ = (
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
class ServerSquad(Base):
__tablename__ = "server_squads"

825
app/handlers/admin/polls.py Normal file
View File

@@ -0,0 +1,825 @@
import html
import logging
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
create_poll,
delete_poll,
get_poll_by_id,
get_poll_statistics,
list_polls,
)
from app.database.models import Poll, User
from app.handlers.admin.messages import (
get_custom_users,
get_custom_users_count,
get_target_display_name,
get_target_users,
get_target_users_count,
)
from app.keyboards.admin import get_admin_communications_submenu_keyboard
from app.localization.texts import get_texts
from app.services.poll_service import send_poll_to_users
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import get_html_help_text, validate_html_tags
logger = logging.getLogger(__name__)
class PollCreationStates(StatesGroup):
waiting_for_title = State()
waiting_for_description = State()
waiting_for_reward = State()
waiting_for_questions = State()
def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
keyboard: list[list[types.InlineKeyboardButton]] = []
for poll in polls[:10]:
keyboard.append(
[
types.InlineKeyboardButton(
text=f"🗳️ {poll.title[:40]}",
callback_data=f"poll_view:{poll.id}",
)
]
)
keyboard.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_CREATE", " Создать опрос"),
callback_data="poll_create",
)
]
)
keyboard.append(
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_communications",
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
def _format_reward_text(poll: Poll, language: str) -> str:
texts = get_texts(language)
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
return texts.t(
"ADMIN_POLLS_REWARD_ENABLED",
"Награда: {amount}",
).format(amount=settings.format_price(poll.reward_amount_kopeks))
return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")
def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"),
callback_data=f"poll_send:{poll_id}",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"),
callback_data=f"poll_stats:{poll_id}",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"),
callback_data=f"poll_delete:{poll_id}",
)
],
[types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")],
]
)
def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"),
callback_data=f"poll_target:{poll_id}:all",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"),
callback_data=f"poll_target:{poll_id}:active",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"),
callback_data=f"poll_target:{poll_id}:trial",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"),
callback_data=f"poll_target:{poll_id}:no",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"),
callback_data=f"poll_target:{poll_id}:expiring",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"),
callback_data=f"poll_target:{poll_id}:expired",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"),
callback_data=f"poll_target:{poll_id}:active_zero",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"),
callback_data=f"poll_target:{poll_id}:trial_zero",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"),
callback_data=f"poll_custom_menu:{poll_id}",
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"poll_view:{poll_id}",
)
],
]
)
def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"),
callback_data=f"poll_custom_target:{poll_id}:today",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"),
callback_data=f"poll_custom_target:{poll_id}:week",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"),
callback_data=f"poll_custom_target:{poll_id}:month",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"),
callback_data=f"poll_custom_target:{poll_id}:active_today",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"),
callback_data=f"poll_custom_target:{poll_id}:inactive_week",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"),
callback_data=f"poll_custom_target:{poll_id}:inactive_month",
),
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"),
callback_data=f"poll_custom_target:{poll_id}:referrals",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"),
callback_data=f"poll_custom_target:{poll_id}:direct",
),
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")],
]
)
def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"),
callback_data=f"poll_send_confirm:{poll_id}:{target}",
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"poll_send:{poll_id}",
)
],
]
)
@admin_required
@error_handler
async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
polls = await list_polls(db)
texts = get_texts(db_user.language)
lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ <b>Опросы</b>"), ""]
if not polls:
lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет."))
else:
for poll in polls[:10]:
reward = _format_reward_text(poll, db_user.language)
lines.append(
f"• <b>{html.escape(poll.title)}</b> — "
f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n"
f" {reward}"
)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=_build_polls_keyboard(polls, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def start_poll_creation(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await state.clear()
await state.set_state(PollCreationStates.waiting_for_title)
await state.update_data(questions=[])
await callback.message.edit_text(
texts.t(
"ADMIN_POLLS_CREATION_TITLE_PROMPT",
"🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def process_poll_title(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
if message.text == "/cancel":
await state.clear()
await message.answer(
get_texts(db_user.language).t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
)
return
title = message.text.strip()
if not title:
await message.answer("❌ Заголовок не может быть пустым. Попробуйте снова.")
return
await state.update_data(title=title)
await state.set_state(PollCreationStates.waiting_for_description)
texts = get_texts(db_user.language)
await message.answer(
texts.t(
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT",
"Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
)
+ f"\n\n{get_html_help_text()}",
parse_mode="HTML",
)
@admin_required
@error_handler
async def process_poll_description(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if message.text == "/cancel":
await state.clear()
await message.answer(
texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
)
return
description: Optional[str]
if message.text == "/skip":
description = None
else:
description = message.text.strip()
is_valid, error_message = validate_html_tags(description)
if not is_valid:
await message.answer(
texts.t("ADMIN_POLLS_CREATION_INVALID_HTML", "❌ Ошибка в HTML: {error}").format(error=error_message)
)
return
await state.update_data(description=description)
await state.set_state(PollCreationStates.waiting_for_reward)
await message.answer(
texts.t(
"ADMIN_POLLS_CREATION_REWARD_PROMPT",
"Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
)
)
def _parse_reward_amount(message_text: str) -> int | None:
normalized = message_text.replace(" ", "").replace(",", ".")
try:
value = Decimal(normalized)
except InvalidOperation:
return None
if value < 0:
value = Decimal(0)
kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
return max(0, kopeks)
@admin_required
@error_handler
async def process_poll_reward(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if message.text == "/cancel":
await state.clear()
await message.answer(
texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
)
return
reward_kopeks = _parse_reward_amount(message.text)
if reward_kopeks is None:
await message.answer(texts.t("ADMIN_POLLS_CREATION_REWARD_INVALID", "❌ Некорректная сумма. Попробуйте ещё раз."))
return
reward_enabled = reward_kopeks > 0
await state.update_data(
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_kopeks,
)
await state.set_state(PollCreationStates.waiting_for_questions)
prompt = texts.t(
"ADMIN_POLLS_CREATION_QUESTION_PROMPT",
(
"Введите вопрос и варианты ответов.\n"
"Каждая строка — отдельный вариант.\n"
"Первая строка — текст вопроса.\n"
"Отправьте /done, когда вопросы будут добавлены."
),
)
await message.answer(prompt)
@admin_required
@error_handler
async def process_poll_question(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if message.text == "/cancel":
await state.clear()
await message.answer(
texts.t("ADMIN_POLLS_CREATION_CANCELLED", "❌ Создание опроса отменено."),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
)
return
if message.text == "/done":
data = await state.get_data()
questions = data.get("questions", [])
if not questions:
await message.answer(
texts.t("ADMIN_POLLS_CREATION_NEEDS_QUESTION", "❌ Добавьте хотя бы один вопрос."),
)
return
title = data.get("title")
description = data.get("description")
reward_enabled = data.get("reward_enabled", False)
reward_amount = data.get("reward_amount_kopeks", 0)
poll = await create_poll(
db,
title=title,
description=description,
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_amount,
created_by=db_user.id,
questions=questions,
)
await state.clear()
reward_text = _format_reward_text(poll, db_user.language)
await message.answer(
texts.t(
"ADMIN_POLLS_CREATION_FINISHED",
"✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
).format(
title=poll.title,
count=len(poll.questions),
reward=reward_text,
),
reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language),
parse_mode="HTML",
)
return
lines = [line.strip() for line in message.text.splitlines() if line.strip()]
if len(lines) < 3:
await message.answer(
texts.t(
"ADMIN_POLLS_CREATION_MIN_OPTIONS",
"❌ Нужен вопрос и минимум два варианта ответа.",
)
)
return
question_text = lines[0]
options = lines[1:]
data = await state.get_data()
questions = data.get("questions", [])
questions.append({"text": question_text, "options": options})
await state.update_data(questions=questions)
await message.answer(
texts.t(
"ADMIN_POLLS_CREATION_ADDED_QUESTION",
"Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
).format(question=question_text),
parse_mode="HTML",
)
async def _render_poll_details(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines = [f"🗳️ <b>{html.escape(poll.title)}</b>"]
if poll.description:
lines.append(poll.description)
lines.append(_format_reward_text(poll, language))
lines.append(
texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(
count=len(poll.questions)
)
)
if poll.questions:
lines.append("")
lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "<b>Вопросы:</b>"))
for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1):
lines.append(f"{idx}. {html.escape(question.text)}")
for option in sorted(question.options, key=lambda o: o.order):
lines.append(
texts.t("ADMIN_POLLS_OPTION_BULLET", "{option}").format(
option=html.escape(option.text)
)
)
return "\n".join(lines)
@admin_required
@error_handler
async def show_poll_details(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
text = await _render_poll_details(poll, db_user.language)
await callback.message.edit_text(
text,
reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def start_poll_send(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"),
reply_markup=_build_target_keyboard(poll.id, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def show_custom_target_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
await callback.message.edit_text(
get_texts(db_user.language).t(
"ADMIN_POLLS_CUSTOM_PROMPT",
"Выберите дополнительный критерий аудитории:",
),
reply_markup=_build_custom_target_keyboard(poll_id, db_user.language),
)
await callback.answer()
async def _show_send_confirmation(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
poll_id: int,
target: str,
user_count: int,
):
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
audience_name = get_target_display_name(target)
texts = get_texts(db_user.language)
confirmation_text = texts.t(
"ADMIN_POLLS_SEND_CONFIRM",
"📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
).format(title=poll.title, audience=audience_name, count=user_count)
await callback.message.edit_text(
confirmation_text,
reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def select_poll_target(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
_, payload = callback.data.split(":", 1)
poll_id_str, target = payload.split(":", 1)
poll_id = int(poll_id_str)
user_count = await get_target_users_count(db, target)
await _show_send_confirmation(callback, db_user, db, poll_id, target, user_count)
@admin_required
@error_handler
async def select_custom_poll_target(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
_, payload = callback.data.split(":", 1)
poll_id_str, criteria = payload.split(":", 1)
poll_id = int(poll_id_str)
user_count = await get_custom_users_count(db, criteria)
await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count)
@admin_required
@error_handler
async def confirm_poll_send(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
_, payload = callback.data.split(":", 1)
poll_id_str, target = payload.split(":", 1)
poll_id = int(poll_id_str)
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
if target.startswith("custom_"):
users = await get_custom_users(db, target.replace("custom_", ""))
else:
users = await get_target_users(db, target)
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."),
parse_mode="HTML",
)
result = await send_poll_to_users(callback.bot, db, poll, users)
result_text = texts.t(
"ADMIN_POLLS_SEND_RESULT",
"📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
).format(**result)
await callback.message.edit_text(
result_text,
reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def show_poll_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
stats = await get_poll_statistics(db, poll_id)
texts = get_texts(db_user.language)
reward_sum = settings.format_price(stats["reward_sum_kopeks"])
lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 <b>Статистика опроса</b>"), ""]
lines.append(
texts.t(
"ADMIN_POLLS_STATS_OVERVIEW",
"Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
).format(
total=stats["total_responses"],
completed=stats["completed_responses"],
reward=reward_sum,
)
)
for question in stats["questions"]:
lines.append("")
lines.append(f"<b>{html.escape(question['text'])}</b>")
for option in question["options"]:
lines.append(
texts.t(
"ADMIN_POLLS_STATS_OPTION_LINE",
"{option}: {count}",
).format(option=html.escape(option["text"]), count=option["count"])
)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=_build_poll_details_keyboard(poll.id, db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def confirm_poll_delete(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
poll = await get_poll_by_id(db, poll_id)
if not poll:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_POLLS_CONFIRM_DELETE",
"Вы уверены, что хотите удалить опрос «{title}»?",
).format(title=poll.title),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"),
callback_data=f"poll_delete_confirm:{poll_id}",
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"poll_view:{poll_id}",
)
],
]
),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def delete_poll_handler(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
poll_id = int(callback.data.split(":")[1])
success = await delete_poll(db, poll_id)
texts = get_texts(db_user.language)
if success:
await callback.message.edit_text(
texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."),
reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language),
)
else:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_polls_panel, F.data == "admin_polls")
dp.callback_query.register(start_poll_creation, F.data == "poll_create")
dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:"))
dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:"))
dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:"))
dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:"))
dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:"))
dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:"))
dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:"))
dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:"))
dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:"))
dp.message.register(process_poll_title, PollCreationStates.waiting_for_title)
dp.message.register(process_poll_description, PollCreationStates.waiting_for_description)
dp.message.register(process_poll_reward, PollCreationStates.waiting_for_reward)
dp.message.register(process_poll_question, PollCreationStates.waiting_for_questions)

197
app/handlers/polls.py Normal file
View File

@@ -0,0 +1,197 @@
import asyncio
import logging
from datetime import datetime
from aiogram import Dispatcher, F, types
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import PollQuestion, User
from app.localization.texts import get_texts
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
logger = logging.getLogger(__name__)
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
try:
await asyncio.sleep(delay)
await bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - cleanup best effort
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
async def _render_question_text(
poll_title: str,
question: PollQuestion,
current_index: int,
total: int,
language: str,
) -> str:
texts = get_texts(language)
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
current=current_index,
total=total,
)
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
return "\n".join(lines)
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
buttons: list[list[types.InlineKeyboardButton]] = []
for option in sorted(question.options, key=lambda o: o.order):
buttons.append(
[
types.InlineKeyboardButton(
text=option.text,
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
async def handle_poll_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
response_id = int(callback.data.split(":")[1])
except (IndexError, ValueError):
await callback.answer("❌ Опрос не найден", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
if not response.poll or not response.poll.questions:
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
return
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
index, question = await get_next_question(response)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
return
question_text = await _render_question_text(
response.poll.title,
question,
index,
len(response.poll.questions),
db_user.language,
)
await callback.message.edit_text(
question_text,
reply_markup=_build_options_keyboard(response.id, question),
parse_mode="HTML",
)
await callback.answer()
async def handle_poll_answer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
_, response_id, question_id, option_id = callback.data.split(":", 3)
response_id = int(response_id)
question_id = int(question_id)
option_id = int(option_id)
except (ValueError, IndexError):
await callback.answer("❌ Некорректные данные", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
texts = get_texts(db_user.language)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
if not response.poll:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
return
option = await get_question_option(question, option_id)
if not option:
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
return
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
response = await get_poll_response_by_id(db, response.id)
index, next_question = await get_next_question(response)
if next_question:
question_text = await _render_question_text(
response.poll.title,
next_question,
index,
len(response.poll.questions),
db_user.language,
)
await callback.message.edit_text(
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
parse_mode="HTML",
)
await callback.answer()
return
response.completed_at = datetime.utcnow()
await db.commit()
reward_amount = await reward_user_for_poll(db, response)
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
if reward_amount:
thanks_lines.append(
texts.t(
"POLL_REWARD_GRANTED",
"Награда {amount} зачислена на ваш баланс.",
).format(amount=settings.format_price(reward_amount))
)
await callback.message.edit_text("\n\n".join(thanks_lines), parse_mode="HTML")
asyncio.create_task(
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))

View File

@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
callback_data="admin_polls",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),

View File

@@ -1375,5 +1375,50 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase"
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
"ADMIN_POLLS_CREATE": " Create poll",
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
"ADMIN_POLLS_SEND": "📤 Send",
"ADMIN_POLLS_STATS": "📊 Stats",
"ADMIN_POLLS_DELETE": "🗑️ Delete",
"ADMIN_POLLS_BACK": "⬅️ Back to list",
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
"POLL_START_BUTTON": "📝 Take the poll",
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
"POLL_EMPTY": "Poll is not available yet.",
"POLL_ERROR": "Unable to process the poll. Please try again later.",
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
}

View File

@@ -1375,5 +1375,50 @@
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка"
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
"ADMIN_POLLS_CREATE": " Создать опрос",
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
"ADMIN_POLLS_SEND": "📤 Отправить",
"ADMIN_POLLS_STATS": "📊 Статистика",
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
"ADMIN_POLLS_BACK": "⬅️ К списку",
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
"POLL_START_BUTTON": "📝 Пройти опрос",
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
"POLL_EMPTY": "Опрос пока недоступен.",
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
}

View File

@@ -0,0 +1,179 @@
import asyncio
import logging
from typing import Iterable
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import add_user_balance
from app.database.models import (
Poll,
PollOption,
PollQuestion,
PollResponse,
TransactionType,
User,
)
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
def _build_poll_invitation_text(poll: Poll, user: User) -> str:
texts = get_texts(user.language)
lines: list[str] = [f"🗳️ <b>{poll.title}</b>"]
if poll.description:
lines.append(poll.description)
if poll.reward_enabled and poll.reward_amount_kopeks > 0:
reward_line = texts.t(
"POLL_INVITATION_REWARD",
"🎁 За участие вы получите {amount}.",
).format(amount=settings.format_price(poll.reward_amount_kopeks))
lines.append(reward_line)
lines.append(
texts.t(
"POLL_INVITATION_START",
"Нажмите кнопку ниже, чтобы пройти опрос.",
)
)
return "\n\n".join(lines)
def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"),
callback_data=f"poll_start:{response_id}",
)
]
]
)
async def send_poll_to_users(
bot: Bot,
db: AsyncSession,
poll: Poll,
users: Iterable[User],
) -> dict:
sent = 0
failed = 0
skipped = 0
for index, user in enumerate(users, start=1):
existing_response = await db.execute(
select(PollResponse.id).where(
and_(
PollResponse.poll_id == poll.id,
PollResponse.user_id == user.id,
)
)
)
if existing_response.scalar_one_or_none():
skipped += 1
continue
response = PollResponse(
poll_id=poll.id,
user_id=user.id,
)
db.add(response)
try:
await db.flush()
text = _build_poll_invitation_text(poll, user)
keyboard = build_start_keyboard(response.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
disable_web_page_preview=True,
)
await db.commit()
sent += 1
if index % 20 == 0:
await asyncio.sleep(1)
except Exception as error: # pragma: no cover - defensive logging
failed += 1
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll.id,
user.telegram_id,
error,
)
await db.rollback()
return {
"sent": sent,
"failed": failed,
"skipped": skipped,
"total": sent + failed + skipped,
}
async def reward_user_for_poll(
db: AsyncSession,
response: PollResponse,
) -> int:
poll = response.poll
if not poll.reward_enabled or poll.reward_amount_kopeks <= 0:
return 0
if response.reward_given:
return response.reward_amount_kopeks
user = response.user
description = f"Награда за участие в опросе \"{poll.title}\""
success = await add_user_balance(
db,
user,
poll.reward_amount_kopeks,
description,
transaction_type=TransactionType.POLL_REWARD,
)
if not success:
return 0
response.reward_given = True
response.reward_amount_kopeks = poll.reward_amount_kopeks
await db.commit()
return poll.reward_amount_kopeks
async def get_next_question(response: PollResponse) -> tuple[int | None, PollQuestion | None]:
if not response.poll or not response.poll.questions:
return None, None
answered_question_ids = {answer.question_id for answer in response.answers}
ordered_questions = sorted(response.poll.questions, key=lambda q: q.order)
for index, question in enumerate(ordered_questions, start=1):
if question.id not in answered_question_ids:
return index, question
return None, None
async def get_question_option(question: PollQuestion, option_id: int) -> PollOption | None:
for option in question.options:
if option.id == option_id:
return option
return None

View File

@@ -0,0 +1,155 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "9f0f2d5a1c7b"
down_revision: Union[str, None] = "8fd1e338eb45"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"polls",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"reward_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
)
op.create_index("ix_polls_id", "polls", ["id"])
op.create_table(
"poll_questions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
)
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
op.create_table(
"poll_options",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column(
"order",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
)
op.create_index("ix_poll_options_id", "poll_options", ["id"])
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
op.create_table(
"poll_responses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("poll_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"sent_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column(
"reward_given",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"reward_amount_kopeks",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
op.create_table(
"poll_answers",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("response_id", sa.Integer(), nullable=False),
sa.Column("question_id", sa.Integer(), nullable=False),
sa.Column("option_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
def downgrade() -> None:
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
op.drop_table("poll_answers")
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
op.drop_table("poll_responses")
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
op.drop_index("ix_poll_options_id", table_name="poll_options")
op.drop_table("poll_options")
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
op.drop_table("poll_questions")
op.drop_index("ix_polls_id", table_name="polls")
op.drop_table("polls")