Merge pull request #1484 from Fr1ngg/dev4

Опросы
This commit is contained in:
Egor
2025-10-23 07:52:15 +03:00
committed by GitHub
12 changed files with 2365 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"

1221
app/handlers/admin/polls.py Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,256 @@
import asyncio
import logging
from datetime import datetime
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
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)
async def _update_poll_message(
message: types.Message,
text: str,
*,
reply_markup: types.InlineKeyboardMarkup | None = None,
parse_mode: str | None = "HTML",
) -> bool:
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return True
except TelegramBadRequest as error:
error_text = str(error).lower()
if "message is not modified" in error_text:
logger.debug(
"Опросное сообщение уже актуально, пропускаем обновление: %s",
error,
)
return True
logger.warning(
"Не удалось обновить сообщение опроса %s: %s",
message.message_id,
error,
)
except Exception as error: # pragma: no cover - defensive logging
logger.exception(
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
message.message_id,
error,
)
return False
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,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
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,
)
try:
await db.refresh(response, attribute_names=["answers"])
except Exception as error: # pragma: no cover - defensive cache busting
logger.debug(
"Не удалось обновить локальные ответы опроса %s: %s",
response.id,
error,
)
response = await get_poll_response_by_id(db, response.id)
if not response:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
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,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
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))
)
if not await _update_poll_message(
callback.message,
"\n\n".join(thanks_lines),
):
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
return
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

@@ -246,6 +246,22 @@ class MulenPayPaymentMixin:
f"Пополнение {display_name}: {payment.amount_kopeks // 100}",
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения %s: %s",
display_name,
error,
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()

View File

@@ -0,0 +1,217 @@
import asyncio
import logging
from types import SimpleNamespace
from typing import Iterable
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
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, language: str) -> str:
texts = get_texts(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
poll_id = poll.id
poll_snapshot = SimpleNamespace(
title=poll.title,
description=poll.description,
reward_enabled=poll.reward_enabled,
reward_amount_kopeks=poll.reward_amount_kopeks,
)
user_snapshots = [
SimpleNamespace(
id=user.id,
telegram_id=user.telegram_id,
language=user.language,
)
for user in users
]
for index, user in enumerate(user_snapshots, 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_snapshot, user.language)
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 TelegramBadRequest as error:
error_text = str(error).lower()
if "chat not found" in error_text or "bot was blocked by the user" in error_text:
skipped += 1
logger.info(
" Пропуск пользователя %s при отправке опроса %s: %s",
user.telegram_id,
poll_id,
error,
)
else: # pragma: no cover - unexpected telegram error
failed += 1
logger.error(
"❌ Ошибка отправки опроса %s пользователю %s: %s",
poll_id,
user.telegram_id,
error,
)
await db.rollback()
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")