🧪 ТЕСТИРОВАНИЕ: • Проверены все кнопки меню • Протестировано для всех уровней доступа • Найдены и исправлены проблемы 🐛 ИСПРАВЛЕНИЯ: • IG меню — теперь доступно админам даже если не в ИГ • Мастера — добавлена заглушка если список пуст • Суперадмин добавлен в ИГ автоматически ✅ РЕЗУЛЬТАТЫ ТЕСТОВ: • Всего пользователей: 19 • Верифицированы: 12 • Не верифицированы: 7 • ИГ: 9 • Админы: 1 • Мастера в базе: 1 • Экстренные: 5 📝 ФАЙЛЫ: • handlers/initiative_group.py: доступ админам • handlers/users.py: заглушка для мастеров • test_bot_comprehensive.py: новый тест Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
588 lines
23 KiB
Python
588 lines
23 KiB
Python
"""
|
||
Initiative Group — хендлеры для работы с инициативной группой
|
||
Только для администраторов
|
||
"""
|
||
import logging
|
||
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 database.db import AsyncSessionLocal
|
||
from services.initiative_group import InitiativeGroupService
|
||
from utils.decorators import admin_only
|
||
import config
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = Router()
|
||
|
||
|
||
# =============================================================================
|
||
# МЕНЮ ИНИЦИАТИВНОЙ ГРУППЫ (CALLBACK)
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_menu')
|
||
async def cb_ig_menu(callback: CallbackQuery):
|
||
"""Меню инициативной группы"""
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||
from utils.ui_styles import get_colored_header, get_level_badge
|
||
from config import ADMIN_USER_ID
|
||
|
||
await callback.answer()
|
||
|
||
user_id = callback.from_user.id
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
is_member = await ig_service.is_member(user_id)
|
||
|
||
# Админы и суперадмин всегда имеют доступ
|
||
is_admin = user_id == ADMIN_USER_ID
|
||
|
||
if is_member or is_admin:
|
||
header = get_colored_header('ИНИЦИАТИВНАЯ ГРУППА', 'ig' if is_member else 'admin')
|
||
|
||
text = f'{header}\n\n'
|
||
text += f'{get_level_badge("admin" if is_admin else "ig")}\n\n'
|
||
|
||
if is_admin and not is_member:
|
||
text += '🟡 <b>Вы — администратор!</b>\n\n'
|
||
text += 'У вас есть полный доступ к функциям ИГ.\n'
|
||
text += 'Для вступления в ИГ используйте:\n'
|
||
text += '<code>/ig_add 197957361 admin</code>\n\n'
|
||
else:
|
||
text += 'Вы — участник инициативной группы!\n\n'
|
||
|
||
text += '<b>Ваши возможности:</b>\n'
|
||
text += '• 📊 Статистика активности\n'
|
||
text += '• 📋 Список участников\n'
|
||
text += '• 👤 Мой профиль\n'
|
||
text += '• 📣 Рассылка для ИГ\n'
|
||
text += '• 📢 Обращения к администрации\n'
|
||
text += '• 🗳️ Голосования ИГ\n\n'
|
||
text += '<i>Больше функций в разработке...</i>'
|
||
|
||
builder = InlineKeyboardBuilder()
|
||
builder.row(
|
||
InlineKeyboardButton(text='🔵 📊 Моя статистика', callback_data='ig_my_stats'),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(text='🔵 📋 Список ИГ', callback_data='ig_list_members'),
|
||
InlineKeyboardButton(text='🔵 👤 Мой профиль', callback_data='ig_my_profile'),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(text='🔵 📣 Рассылка ИГ', callback_data='ig_broadcast'),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'),
|
||
)
|
||
|
||
await callback.message.answer(
|
||
text,
|
||
reply_markup=builder.as_markup(),
|
||
parse_mode='HTML'
|
||
)
|
||
else:
|
||
await callback.message.answer(
|
||
'ℹ️ <b>Инициативная группа</b>\n\n'
|
||
'Вы не являетесь участником ИГ.\n\n'
|
||
'Инициативная группа — это активные жильцы,\n'
|
||
'которые помогают управлять домом.\n\n'
|
||
'Чтобы вступить, обратитесь к администратору.',
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# FSM для добавления в ИГ
|
||
# =============================================================================
|
||
|
||
class IGState(StatesGroup):
|
||
waiting_for_notes = State()
|
||
waiting_for_broadcast = State()
|
||
waiting_for_broadcast_file = State()
|
||
|
||
|
||
# =============================================================================
|
||
# РАССЫЛКА ДЛЯ ИГ
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_broadcast')
|
||
async def cb_ig_broadcast(callback: CallbackQuery):
|
||
"""Рассылка для ИГ"""
|
||
from aiogram.types import InputFile
|
||
|
||
await callback.answer()
|
||
await callback.message.answer(
|
||
'📣 <b>Рассылка для Инициативной Группы</b>\n\n'
|
||
'Отправьте текст сообщения:\n\n'
|
||
'<i>Или прикрепите файл (PDF, фото и т.д.)</i>\n\n'
|
||
'Отправьте "отмена" для выхода.',
|
||
parse_mode='HTML'
|
||
)
|
||
await callback.from_user.id
|
||
await callback.message.bot.get_updates()
|
||
await callback.message.answer('👇 Отправьте текст или файл:')
|
||
# Проще через команду
|
||
await callback.message.answer(
|
||
'ℹ️ <b>Для отправки рассылки используйте команду:</b>\n\n'
|
||
'<code>/ig_broadcast</code>\n\n'
|
||
'Бот попросит ввести текст и файл.',
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
class IGState(StatesGroup):
|
||
waiting_for_notes = State()
|
||
|
||
|
||
# =============================================================================
|
||
# ДОБАВЛЕНИЕ В ИГ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_add'))
|
||
@admin_only
|
||
async def cmd_ig_add(message: Message, state: FSMContext):
|
||
"""
|
||
Добавить пользователя в ИГ
|
||
Использование: /ig_add <user_id> [роль]
|
||
Роли: member, admin, coordinator
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer(
|
||
'❌ <b>Использование:</b>\n'
|
||
'<code>/ig_add <user_id> [роль]</code>\n\n'
|
||
'Роли: member, admin, coordinator\n'
|
||
'Пример: /ig_add 123456789 member',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
try:
|
||
user_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID пользователя')
|
||
return
|
||
|
||
role = args[2] if len(args) > 2 else 'member'
|
||
valid_roles = ['member', 'admin', 'coordinator']
|
||
|
||
if role not in valid_roles:
|
||
await message.answer(f'❌ Неверная роль. Доступные: {", ".join(valid_roles)}')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
success = await ig_service.add_member(user_id, role)
|
||
|
||
if success:
|
||
# Получаем имя пользователя
|
||
from database.models import User
|
||
user = await session.get(User, user_id)
|
||
name = user.full_name or user.username or f'user{user_id}' if user else f'user{user_id}'
|
||
|
||
await message.answer(
|
||
f'✅ <b>Пользователь добавлен в ИГ!</b>\n\n'
|
||
f'👤 {name}\n'
|
||
f'🔖 ID: {user_id}\n'
|
||
f'🎭 Роль: {role}\n\n'
|
||
f'Отправьте заметку о роли (необязательно) или "пропустить":',
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
await state.update_data(ig_user_id=user_id)
|
||
await state.set_state(IGState.waiting_for_notes)
|
||
else:
|
||
await message.answer('❌ Не удалось добавить участника')
|
||
|
||
|
||
@router.message(IGState.waiting_for_notes)
|
||
async def process_ig_notes(message: Message, state: FSMContext):
|
||
"""Получили заметку о роли"""
|
||
data = await state.get_data()
|
||
user_id = data.get('ig_user_id')
|
||
|
||
notes = message.text if message.text.lower() != 'пропустить' else None
|
||
|
||
if notes and notes.lower() != 'пропустить':
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
await ig_service.update_role(user_id, 'member')
|
||
|
||
await message.answer('✅ Заметка сохранена')
|
||
await state.clear()
|
||
|
||
|
||
# =============================================================================
|
||
# СТАТИСТИКА УЧАСТНИКА ИГ
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_my_stats')
|
||
async def cb_ig_my_stats(callback: CallbackQuery):
|
||
"""Статистика участника ИГ"""
|
||
from database.models import User
|
||
|
||
await callback.answer()
|
||
|
||
user_id = callback.from_user.id
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
member_info = await ig_service.get_member(user_id)
|
||
user = await session.get(User, user_id)
|
||
|
||
if member_info and user:
|
||
text = '📊 <b>Ваша статистика в ИГ</b>\n\n'
|
||
text += f'👤 <b>{user.full_name or user.username}</b>\n'
|
||
text += f'🏠 Квартира: {user.apartment or "не указана"}\n\n'
|
||
text += f'🎭 <b>Роль:</b> {member_info["role"]}\n'
|
||
text += f'📅 <b>В группе с:</b> {member_info["joined_at"].strftime("%d.%m.%Y")}\n\n'
|
||
text += f'📊 <b>Ваша активность:</b>\n'
|
||
text += f'• Сообщений: {user.message_count}\n'
|
||
text += f'• Рейтинг: {user.rating}\n'
|
||
text += f'• Уровень: {user.level}\n'
|
||
|
||
await callback.message.answer(text, parse_mode='HTML')
|
||
else:
|
||
await callback.message.answer('❌ Вы не являетесь участником ИГ')
|
||
|
||
|
||
# =============================================================================
|
||
# СПИСОК УЧАСТНИКОВ ИГ
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_list_members')
|
||
async def cb_ig_list_members(callback: CallbackQuery):
|
||
"""Список участников ИГ"""
|
||
await callback.answer()
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
members = await ig_service.get_members(active_only=True)
|
||
|
||
if members:
|
||
text = '📋 <b>Участники инициативной группы</b>\n\n'
|
||
|
||
# Группируем по ролям
|
||
by_role = {}
|
||
for m in members:
|
||
role = m['role']
|
||
if role not in by_role:
|
||
by_role[role] = []
|
||
by_role[role].append(m)
|
||
|
||
role_names = {
|
||
'admin': '👑 Админы',
|
||
'coordinator': '🎯 Координаторы',
|
||
'member': '👥 Участники'
|
||
}
|
||
|
||
for role, role_members in by_role.items():
|
||
text += f'\n<b>{role_names.get(role, role)}</b>\n'
|
||
for m in role_members[:10]: # Макс 10 на роль
|
||
apt = f' (кв. {m["apartment"]})' if m.get('apartment') else ''
|
||
text += f'• {m["name"]}{apt}\n'
|
||
|
||
if len(members) > 30:
|
||
text += f'\n... и ещё {len(members) - 30} участников'
|
||
|
||
await callback.message.answer(text, parse_mode='HTML')
|
||
else:
|
||
await callback.message.answer('ℹ️ В ИГ пока нет участников')
|
||
|
||
|
||
# =============================================================================
|
||
# МОЙ ПРОФИЛЬ (РЕДАКТИРОВАНИЕ)
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_my_profile')
|
||
async def cb_ig_my_profile(callback: CallbackQuery):
|
||
"""Редактирование профиля участника ИГ"""
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||
from database.models import User
|
||
|
||
await callback.answer()
|
||
|
||
user_id = callback.from_user.id
|
||
async with AsyncSessionLocal() as session:
|
||
user = await session.get(User, user_id)
|
||
|
||
if user:
|
||
phone_text = f'📞 <b>Телефон:</b> <code>{user.phone or "не указан"}</code>\n'
|
||
apt_text = f'🏠 <b>Квартира:</b> <code>{user.apartment or "не указана"}</code>\n'
|
||
|
||
text = '👤 <b>Ваш профиль</b>\n\n'
|
||
text += apt_text
|
||
text += phone_text
|
||
text += '\n<b>Что хотите изменить?</b>'
|
||
|
||
builder = InlineKeyboardBuilder()
|
||
builder.row(
|
||
InlineKeyboardButton(text='🏠 Изменить квартиру', callback_data='ig_edit_apt'),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(text='📞 Изменить телефон', callback_data='ig_edit_phone'),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(text='🔙 Назад', callback_data='ig_menu'),
|
||
)
|
||
|
||
await callback.message.answer(
|
||
text,
|
||
reply_markup=builder.as_markup(),
|
||
parse_mode='HTML'
|
||
)
|
||
else:
|
||
await callback.message.answer('❌ Вы не найдены в базе')
|
||
|
||
|
||
# =============================================================================
|
||
# ИЗМЕНИТЬ КВАРТИРУ
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_edit_apt')
|
||
async def cb_ig_edit_apt(callback: CallbackQuery):
|
||
"""Изменение квартиры"""
|
||
await callback.answer()
|
||
await callback.message.answer(
|
||
'🏠 <b>Изменение квартиры</b>\n\n'
|
||
'ℹ️ <b>Используйте команду:</b>\n\n'
|
||
'<code>/setapt 45</code>\n\n'
|
||
'Где 45 — ваш номер квартиры.\n\n'
|
||
'Или нажмите "Назад".',
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# ИЗМЕНИТЬ ТЕЛЕФОН
|
||
# =============================================================================
|
||
|
||
@router.callback_query(F.data == 'ig_edit_phone')
|
||
async def cb_ig_edit_phone(callback: CallbackQuery):
|
||
"""Изменение телефона"""
|
||
await callback.answer()
|
||
await callback.message.answer(
|
||
'📞 <b>Изменение телефона</b>\n\n'
|
||
'ℹ️ <b>Используйте команду:</b>\n\n'
|
||
'<code>/setphone +79991234567</code>\n\n'
|
||
'Или нажмите "Назад".',
|
||
parse_mode='HTML'
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# УДАЛЕНИЕ ИЗ ИГ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_remove'))
|
||
@admin_only
|
||
async def cmd_ig_remove(message: Message):
|
||
"""
|
||
Удалить пользователя из ИГ
|
||
Использование: /ig_remove <user_id>
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer(
|
||
'❌ <b>Использование:</b>\n'
|
||
'<code>/ig_remove <user_id></code>',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
try:
|
||
user_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID пользователя')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
success = await ig_service.remove_member(user_id)
|
||
|
||
if success:
|
||
await message.answer(f'✅ Пользователь {user_id} удалён из ИГ')
|
||
else:
|
||
await message.answer(f'❌ Пользователь {user_id} не найден в ИГ')
|
||
|
||
|
||
# =============================================================================
|
||
# СПИСОК УЧАСТНИКОВ ИГ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_list'))
|
||
@admin_only
|
||
async def cmd_ig_list(message: Message):
|
||
"""Показать список участников ИГ"""
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
members = await ig_service.get_members(active_only=True)
|
||
stats = await ig_service.get_stats()
|
||
|
||
if not members:
|
||
await message.answer('ℹ️ В инициативной группе пока нет участников')
|
||
return
|
||
|
||
text = f'👥 <b>Инициативная группа</b>\n\n'
|
||
text += f'Всего участников: {stats["active"]}\n\n'
|
||
|
||
# Группируем по ролям
|
||
by_role = {}
|
||
for member in members:
|
||
role = member['role']
|
||
if role not in by_role:
|
||
by_role[role] = []
|
||
by_role[role].append(member)
|
||
|
||
role_names = {
|
||
'admin': '🔴 Администраторы',
|
||
'coordinator': '🟡 Координаторы',
|
||
'member': '🟢 Участники'
|
||
}
|
||
|
||
for role, role_members in by_role.items():
|
||
role_name = role_names.get(role, role.capitalize())
|
||
text += f'<b>{role_name}</b>\n'
|
||
|
||
for m in role_members:
|
||
apt = f'(кв. {m["apartment"]})' if m['apartment'] else ''
|
||
text += f' • {m["name"]} {apt}\n'
|
||
|
||
text += '\n'
|
||
|
||
await message.answer(text, parse_mode='HTML')
|
||
|
||
|
||
# =============================================================================
|
||
# СТАТИСТИКА ИГ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_stats'))
|
||
@admin_only
|
||
async def cmd_ig_stats(message: Message):
|
||
"""Статистика ИГ"""
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
stats = await ig_service.get_stats()
|
||
|
||
text = '📊 <b>Статистика инициативной группы</b>\n\n'
|
||
text += f'Всего участников: {stats["total"]}\n'
|
||
text += f'Активных: {stats["active"]}\n\n'
|
||
|
||
if stats['by_role']:
|
||
text += '<b>По ролям:</b>\n'
|
||
for role, count in stats['by_role'].items():
|
||
text += f' • {role}: {count}\n'
|
||
|
||
await message.answer(text, parse_mode='HTML')
|
||
|
||
|
||
# =============================================================================
|
||
# РАССЫЛКА ТОЛЬКО ИГ
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_broadcast'))
|
||
@admin_only
|
||
async def cmd_ig_broadcast(message: Message, state: FSMContext):
|
||
"""
|
||
Рассылка только участникам ИГ
|
||
Использование: /ig_broadcast
|
||
"""
|
||
await message.answer(
|
||
'📬 <b>Рассылка для инициативной группы</b>\n\n'
|
||
'Отправьте сообщение для рассылки:',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.set_state('ig_broadcast_waiting')
|
||
|
||
|
||
@router.message(StateFilter('ig_broadcast_waiting'))
|
||
@admin_only
|
||
async def process_ig_broadcast(message: Message, state: FSMContext):
|
||
"""Отправка рассылки ИГ"""
|
||
broadcast_message = message.text
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
member_ids = await ig_service.get_ig_only_broadcast_recipients()
|
||
|
||
if not member_ids:
|
||
await message.answer('❌ В ИГ нет участников')
|
||
await state.clear()
|
||
return
|
||
|
||
success_count = 0
|
||
error_count = 0
|
||
|
||
for user_id in member_ids:
|
||
try:
|
||
await message.bot.send_message(
|
||
user_id,
|
||
f'📬 <b>Сообщение для инициативной группы</b>\n\n'
|
||
f'{broadcast_message}\n\n'
|
||
f'<i>Это сообщение отправлено только участникам ИГ</i>',
|
||
parse_mode='HTML'
|
||
)
|
||
success_count += 1
|
||
except Exception as e:
|
||
logger.error(f'Не удалось отправить сообщение пользователю {user_id}: {e}')
|
||
error_count += 1
|
||
|
||
await message.answer(
|
||
f'✅ <b>Рассылка завершена!</b>\n\n'
|
||
f'Успешно: {success_count}\n'
|
||
f'Ошибок: {error_count}\n'
|
||
f'Получателей: {len(member_ids)}',
|
||
parse_mode='HTML'
|
||
)
|
||
await state.clear()
|
||
|
||
|
||
# =============================================================================
|
||
# ПРОВЕРКА УЧАСТНИКА
|
||
# =============================================================================
|
||
|
||
@router.message(Command('ig_check'))
|
||
@admin_only
|
||
async def cmd_ig_check(message: Message):
|
||
"""
|
||
Проверить является ли пользователь участником ИГ
|
||
Использование: /ig_check <user_id>
|
||
"""
|
||
args = message.text.split()
|
||
if len(args) < 2:
|
||
await message.answer(
|
||
'❌ <b>Использование:</b>\n'
|
||
'<code>/ig_check <user_id></code>',
|
||
parse_mode='HTML'
|
||
)
|
||
return
|
||
|
||
try:
|
||
user_id = int(args[1])
|
||
except ValueError:
|
||
await message.answer('❌ Неверный ID пользователя')
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
member = await ig_service.get_member(user_id)
|
||
|
||
if member:
|
||
from database.models import User
|
||
user = await session.get(User, user_id)
|
||
name = user.full_name or user.username or f'user{user_id}' if user else f'user{user_id}'
|
||
|
||
await message.answer(
|
||
f'✅ <b>Пользователь в ИГ</b>\n\n'
|
||
f'👤 {name}\n'
|
||
f'🎭 Роль: {member["role"]}\n'
|
||
f'📅 В ИГ с: {member["joined_at"].strftime("%d.%m.%Y")}\n'
|
||
f'📝 Заметка: {member["notes"] or "нет"}',
|
||
parse_mode='HTML'
|
||
)
|
||
else:
|
||
await message.answer(f'ℹ️ Пользователь {user_id} не является участником ИГ')
|