domovoy_bot/handlers/initiative_group.py
Admin 75a17a3de5 v1.34 Комплексное тестирование и исправления
🧪 ТЕСТИРОВАНИЕ:
• Проверены все кнопки меню
• Протестировано для всех уровней доступа
• Найдены и исправлены проблемы

🐛 ИСПРАВЛЕНИЯ:
• 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>
2026-03-17 15:57:34 +04:00

588 lines
23 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.

"""
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 &lt;user_id&gt; [роль]</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 &lt;user_id&gt;</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 &lt;user_id&gt;</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} не является участником ИГ')