""" Опросы и голосования Только для администраторов """ 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 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( '⚠️ Опросы создаются только в чате!\n\n' 'Напишите команду в общем чате:\n' '/poll_create "Вопрос" "Вариант 1" "Вариант 2"', parse_mode='HTML' ) return # Парсим аргументы args = message.text.split(maxsplit=1) if len(args) < 2: # Режим диалога await message.answer( '📊 Создание опроса\n\n' 'Отправьте вопрос опроса (или используйте команду с аргументами):\n' '/poll_create "За какой цвет покрасить подъезд?" "Бежевый" "Зелёный" "Серый"', 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( '❌ Ошибка!\n\n' 'Нужно минимум 3 части:\n' '1. Вопрос\n' '2. Вариант 1\n' '3. Вариант 2\n\n' 'Пример:\n' '/poll_create "За какой цвет?" "Бежевый" "Зелёный"', 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'📊 ОПРОС\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() await message.answer( f'✅ Опрос создан!\n\n' f'Вопрос: {question}\n' f'Вариантов: {len(options)}\n\n' f'Для закрытия: /poll_end {poll.poll_id}', 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' 'Вариант 1, Вариант 2, Вариант 3\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 = '📊 Результаты голосования\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.answer( results_text, 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'📊 Результаты опроса\n\n' f'Вопрос: {poll.question}\n' f'Статус: {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 """ args = message.text.split() if len(args) < 2: await message.answer( '❌ Использование:\n' '/poll_end <номер_опроса>\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'🔒 ОПРОС ЗАКРЫТ\n\n' f'Вопрос: {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') # ============================================================================= # СПИСОК ОПРОСОВ # ============================================================================= @router.message(Command('polls')) async def cmd_polls(message: Message): """Список активных опросов""" async with AsyncSessionLocal() as session: stmt = select(Poll).where( Poll.chat_id == message.chat.id, Poll.is_active == True ).order_by(Poll.created_at.desc()) result = await session.execute(stmt) polls = list(result.scalars().all()) if not polls: await message.answer('📊 Нет активных опросов') return text = '📊 Активные опросы\n\n' for poll in polls: total = poll.total_votes status = '🔒 Закрыт' if not poll.is_active else '🟢 Активен' text += f'{poll.poll_id}. {poll.question[:50]}... ({total} голосов) {status}\n' text += '\n💡 Для голосования нажмите на кнопку в сообщении с опросом' await message.answer(text, 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('ℹ️ Нет активного действия для отмены')