✅ Регистрация и верификация жильцов ✅ Рейтинг активности (уровни) ✅ Детект шпионов (Score 0-100) ✅ Анти-мат система (3 предупреждения → бан) ✅ Админ-панель с рассылкой ✅ Учёт квартир (несколько жильцов) ✅ Телефоны экстренных служб и мастеров ✅ Еженедельный экспорт JSON ✅ Прокси (socks5) для обхода РКН Бекап: backups/versions/v1.0_mvp_2026-02-26/ Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""
|
||
Опросы и голосования
|
||
Только для администраторов
|
||
"""
|
||
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(
|
||
'⚠️ <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()
|
||
|
||
await message.answer(
|
||
f'✅ Опрос создан!\n\n'
|
||
f'Вопрос: {question}\n'
|
||
f'Вариантов: {len(options)}\n\n'
|
||
f'Для закрытия: <code>/poll_end {poll.poll_id}</code>',
|
||
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.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'📊 <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')
|
||
|
||
|
||
# =============================================================================
|
||
# СПИСОК ОПРОСОВ
|
||
# =============================================================================
|
||
|
||
@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 = '📊 <b>Активные опросы</b>\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('ℹ️ Нет активного действия для отмены')
|