mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Merge pull request #1460 from Fr1ngg/revert-1455-revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Revert "Add poll management and delivery system""
This commit is contained in:
16
app/bot.py
16
app/bot.py
@@ -16,9 +16,18 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -31,6 +40,7 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
@@ -145,6 +155,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
@@ -163,6 +174,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
|
||||
265
app/database/crud/poll.py
Normal file
265
app/database/crud/poll.py
Normal 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,
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
825
app/handlers/admin/polls.py
Normal 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
197
app/handlers/polls.py
Normal 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:"))
|
||||
@@ -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", "🎯 Промо-предложения"),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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} зачислена на ваш баланс."
|
||||
}
|
||||
|
||||
179
app/services/poll_service.py
Normal file
179
app/services/poll_service.py
Normal 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
|
||||
155
migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py
Normal file
155
migrations/alembic/versions/9f0f2d5a1c7b_add_polls_tables.py
Normal 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")
|
||||
Reference in New Issue
Block a user