domovoy_bot/handlers/polls.py

465 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Опросы и голосования
Только для администраторов
"""
import logging
import json
from datetime import datetime
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command, StateFilter
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database.db import AsyncSessionLocal
from database.models import User, Poll
from utils.decorators import admin_only
from keyboards.inline import (
get_poll_keyboard, get_poll_results_keyboard, get_poll_close_keyboard,
get_polls_list_keyboard, get_poll_view_keyboard
)
from swarmlib.notifier import send_telegram
import config
logger = logging.getLogger(__name__)
router = Router()
# =============================================================================
# FSM для создания опроса
# =============================================================================
class PollCreateState(StatesGroup):
waiting_for_question = State()
waiting_for_options = State()
# =============================================================================
# СОЗДАНИЕ ОПРОСА
# =============================================================================
@router.message(Command('poll_create'))
@admin_only
async def cmd_poll_create(message: Message, state: FSMContext):
"""
Создание опроса
Использование: /poll_create "Вопрос" "Вариант 1" "Вариант 2" ...
Или в режиме диалога
"""
# Проверяем что это личка или чат
if message.chat.type == 'private':
await message.answer(
'⚠️ <b>Опросы создаются только в чате!</b>\n\n'
'Напишите команду в общем чате:\n'
'<code>/poll_create "Вопрос" "Вариант 1" "Вариант 2"</code>',
parse_mode='HTML'
)
return
# Парсим аргументы
args = message.text.split(maxsplit=1)
if len(args) < 2:
# Режим диалога
await message.answer(
'📊 <b>Создание опроса</b>\n\n'
'Отправьте вопрос опроса (или используйте команду с аргументами):\n'
'<code>/poll_create "За какой цвет покрасить подъезд?" "Бежевый" "Зелёный" "Серый"</code>',
parse_mode='HTML'
)
await state.set_state(PollCreateState.waiting_for_question)
return
# Парсим вопрос и варианты
text = args[1]
await create_poll_from_text(message, text, state)
async def create_poll_from_text(message: Message, text: str, state: FSMContext):
"""Создать опрос из текста"""
# Простая парсинг: разделяем по кавычкам или пробелам
import shlex
try:
parts = shlex.split(text)
except ValueError:
parts = text.split('"')
parts = [p.strip() for p in parts if p.strip()]
if len(parts) < 3:
await message.answer(
'❌ <b>Ошибка!</b>\n\n'
'Нужно минимум 3 части:\n'
'1. Вопрос\n'
'2. Вариант 1\n'
'3. Вариант 2\n\n'
'Пример:\n'
'<code>/poll_create "За какой цвет?" "Бежевый" "Зелёный"</code>',
parse_mode='HTML'
)
return
question = parts[0]
options = parts[1:]
if len(options) > 10:
options = options[:10]
await message.answer('⚠️ Максимум 10 вариантов, лишние обрезаны')
# Создаём опрос в БД
async with AsyncSessionLocal() as session:
poll = Poll(
message_id=0, # Заполним после отправки
chat_id=message.chat.id,
question=question,
options=json.dumps(options, ensure_ascii=False),
votes=json.dumps({str(i): [] for i in range(len(options))}, ensure_ascii=False),
created_by=message.from_user.id,
)
session.add(poll)
await session.commit()
await session.refresh(poll)
# Отправляем опрос с кнопками
keyboard = get_poll_keyboard(poll.poll_id, options)
poll_text = (
f'📊 <b>ОПРОС</b>\n\n'
f'{question}\n\n'
f'Голосуйте кнопками ниже! 👇'
)
sent_message = await message.answer(poll_text, reply_markup=keyboard, parse_mode='HTML')
# Обновляем message_id
async with AsyncSessionLocal() as session:
poll.message_id = sent_message.message_id
await session.commit()
# Уведомляем админа через swarmlib
send_telegram(f"📊 *Новый опрос создан!*\n\n"
f"Вопрос: {question}\n"
f"Вариантов: {len(options)}\n"
f"Автор: {message.from_user.full_name}",
parse_mode="markdown")
await message.answer(
f'✅ Опрос создан!\n\n'
f'Вопрос: {question}\n'
f'Вариантов: {len(options)}\n\n'
f'Опрос доступен в меню "Опросы"',
parse_mode='HTML'
)
await state.clear()
@router.message(PollCreateState.waiting_for_question)
async def process_question(message: Message, state: FSMContext):
"""Получили вопрос опроса"""
question = message.text
await state.update_data(question=question)
await state.set_state(PollCreateState.waiting_for_options)
await message.answer(
'📝 Теперь отправьте варианты ответов через запятую:\n\n'
'<code>Вариант 1, Вариант 2, Вариант 3</code>\n\n'
'Или отмените: /cancel',
parse_mode='HTML'
)
@router.message(PollCreateState.waiting_for_options)
async def process_options(message: Message, state: FSMContext):
"""Получили варианты опроса"""
options_text = message.text
options = [o.strip() for o in options_text.split(',')]
if len(options) < 2:
await message.answer(
'❌ Нужно минимум 2 варианта!\n'
'Отправьте ещё раз через запятую:'
)
return
data = await state.get_data()
question = data.get('question')
await state.clear()
await create_poll_from_text(message, f'"{question}" ' + ' '.join([f'"{o}"' for o in options]), state)
# =============================================================================
# ГОЛОСОВАНИЕ
# =============================================================================
@router.callback_query(F.data.startswith('poll_vote_'))
async def callback_poll_vote(callback: CallbackQuery):
"""Голосование за вариант"""
# poll_vote_{poll_id}_{option_index}
parts = callback.data.split('_')
poll_id = int(parts[2])
option_index = int(parts[3])
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
# Получаем опрос
stmt = select(Poll).where(Poll.poll_id == poll_id)
result = await session.execute(stmt)
poll = result.scalar_one_or_none()
if not poll:
await callback.answer('❌ Опрос не найден', show_alert=True)
return
if not poll.is_active:
await callback.answer('⛔ Опрос уже закрыт', show_alert=True)
return
# Проверяем что пользователь верифицирован
user_stmt = select(User).where(User.user_id == user_id)
result = await session.execute(user_stmt)
user = result.scalar_one_or_none()
# Голосовать могут все (даже неверифицированные)
# Но можно добавить проверку если нужно
# Получаем текущие голоса
votes_data = json.loads(poll.votes) if poll.votes else {}
# Убираем старый голос этого пользователя если есть
for key in votes_data:
if str(user_id) in votes_data[key]:
votes_data[key].remove(str(user_id))
# Добавляем новый голос
key = str(option_index)
if key not in votes_data:
votes_data[key] = []
votes_data[key].append(str(user_id))
# Сохраняем
poll.votes = json.dumps(votes_data, ensure_ascii=False)
await session.commit()
# Получаем результаты
results = poll.get_results()
total = poll.total_votes
# Формируем текст результатов
results_text = '📊 <b>Результаты голосования</b>\n\n'
for option, count, percent in results:
bar = '' * int(percent / 10) # Визуальная шкала
results_text += f'{bar} {option}: {count} ({percent}%)\n'
results_text += f'\n👥 Всего голосов: {total}'
await callback.answer(f'✅ Ваш голос учтён!', show_alert=False)
# Обновляем текущее сообщение результатами
await callback.message.edit_text(
results_text,
reply_markup=get_poll_view_keyboard(poll_id, user.is_admin if user else False),
parse_mode='HTML'
)
# =============================================================================
# ПРОСМОТР РЕЗУЛЬТАТОВ
# =============================================================================
@router.callback_query(F.data.startswith('poll_results_'))
async def callback_poll_results(callback: CallbackQuery):
"""Показать результаты опроса"""
parts = callback.data.split('_')
poll_id = int(parts[2])
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(Poll.poll_id == poll_id)
result = await session.execute(stmt)
poll = result.scalar_one_or_none()
if not poll:
await callback.answer('❌ Опрос не найден', show_alert=True)
return
results = poll.get_results()
total = poll.total_votes
status = '🔒 Закрыт' if not poll.is_active else '🟢 Активен'
text = (
f'📊 <b>Результаты опроса</b>\n\n'
f'<b>Вопрос:</b> {poll.question}\n'
f'<b>Статус:</b> {status}\n\n'
)
for option, count, percent in results:
bar = '' * int(percent / 10)
text += f'{bar} {option}: {count} ({percent}%)\n'
text += f'\n👥 Всего голосов: {total}'
await callback.message.answer(text, parse_mode='HTML')
await callback.answer()
# =============================================================================
# ЗАКРЫТИЕ ОПРОСА
# =============================================================================
@router.message(Command('poll_end'))
@admin_only
async def cmd_poll_end(message: Message):
"""
Закрыть опрос
Использование: /poll_end <poll_id>
"""
args = message.text.split()
if len(args) < 2:
await message.answer(
'❌ <b>Использование:</b>\n'
'<code>/poll_end <номер_опроса></code>\n\n'
'Номер опроса можно узнать из команды создания.',
parse_mode='HTML'
)
return
try:
poll_id = int(args[1])
except ValueError:
await message.answer('❌ Неверный номер опроса')
return
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(Poll.poll_id == poll_id)
result = await session.execute(stmt)
poll = result.scalar_one_or_none()
if not poll:
await message.answer('❌ Опрос не найден')
return
if not poll.is_active:
await message.answer('⛔ Опрос уже закрыт')
return
# Закрываем опрос
poll.is_active = False
poll.closed_at = datetime.utcnow()
await session.commit()
# Показываем финальные результаты
results = poll.get_results()
total = poll.total_votes
text = (
f'🔒 <b>ОПРОС ЗАКРЫТ</b>\n\n'
f'<b>Вопрос:</b> {poll.question}\n\n'
)
for option, count, percent in results:
bar = '' * int(percent / 10)
text += f'{bar} {option}: {count} ({percent}%)\n'
text += f'\n👥 Всего голосов: {total}'
await message.answer(text, parse_mode='HTML')
# =============================================================================
# СПИСОК ОПРОСОВ (INLINE)
# =============================================================================
@router.message(Command('polls'))
@router.callback_query(F.data == 'polls')
async def cmd_polls(event):
"""Список активных опросов (Inline)"""
chat_id = event.chat.id if isinstance(event, Message) else event.message.chat.id
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(
Poll.is_active == True
).order_by(Poll.created_at.desc())
result = await session.execute(stmt)
polls = list(result.scalars().all())
text = '📊 <b>Активные опросы дома</b>\n\nВыберите опрос для голосования или просмотра результатов:'
if not polls:
text = '📊 <b>Активных опросов пока нет</b>'
keyboard = get_polls_list_keyboard(polls)
if isinstance(event, Message):
await event.answer(text, reply_markup=keyboard, parse_mode='HTML')
else:
await event.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
@router.callback_query(F.data.startswith('poll_view_'))
async def cb_poll_view(callback: CallbackQuery):
"""Детальный просмотр опроса"""
poll_id = int(callback.data.split('_')[2])
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(Poll.poll_id == poll_id)
result = await session.execute(stmt)
poll = result.scalar_one_or_none()
if not poll:
await callback.answer('❌ Опрос не найден', show_alert=True)
return
# Проверяем админа
stmt_user = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt_user)).scalar_one_or_none()
is_admin = user.is_admin if user else False
status = '🟢 Активен' if poll.is_active else '🔒 Закрыт'
text = (
f'📊 <b>Опрос: {poll.question}</b>\n\n'
f'<b>Статус:</b> {status}\n'
f'<b>Всего голосов:</b> {poll.total_votes}\n'
)
await callback.message.edit_text(
text,
reply_markup=get_poll_view_keyboard(poll_id, is_admin),
parse_mode='HTML'
)
@router.callback_query(F.data.startswith('poll_vote_menu_'))
async def cb_poll_vote_menu(callback: CallbackQuery):
"""Меню выбора варианта для голосования"""
poll_id = int(callback.data.split('_')[3])
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(Poll.poll_id == poll_id)
result = await session.execute(stmt)
poll = result.scalar_one_or_none()
if not poll or not poll.is_active:
await callback.answer('❌ Опрос закрыт или не найден', show_alert=True)
return
options = json.loads(poll.options)
await callback.message.edit_text(
f'🗳️ <b>{poll.question}</b>\n\nВыберите ваш вариант:',
reply_markup=get_poll_keyboard(poll_id, options),
parse_mode='HTML'
)
# =============================================================================
# ОТМЕНА
# =============================================================================
@router.message(Command('cancel'))
async def cmd_cancel(message: Message, state: FSMContext):
"""Отмена текущего действия"""
current_state = await state.get_state()
if current_state:
await state.clear()
await message.answer('❌ Отменено')
else:
await message.answer(' Нет активного действия для отмены')