diff --git a/database/migrate_add_initiative_group.py b/database/migrate_add_initiative_group.py new file mode 100644 index 0000000..ddb5e81 --- /dev/null +++ b/database/migrate_add_initiative_group.py @@ -0,0 +1,49 @@ +""" +Миграция v2.1: создать таблицу initiative_group для инициативной группы +""" +import sqlite3 +import asyncio + +DB_PATH = 'database/domovoy.db' + + +async def migrate(): + """Создать таблицу initiative_group""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Проверяем, есть ли уже таблица initiative_group + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='initiative_group'") + existing = cursor.fetchone() + + if existing: + print("ℹ️ Таблица initiative_group уже существует") + else: + print("➕ Создаём таблицу initiative_group...") + + cursor.execute(""" + CREATE TABLE initiative_group ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id BIGINT NOT NULL UNIQUE, + role TEXT DEFAULT 'member', + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + notes TEXT, + FOREIGN KEY (user_id) REFERENCES users(user_id) + ) + """) + + # Создаём индексы + cursor.execute("CREATE INDEX ix_initiative_group_user ON initiative_group(user_id)") + cursor.execute("CREATE INDEX ix_initiative_group_active ON initiative_group(is_active)") + + print("✅ Таблица initiative_group создана") + print("✅ Индексы созданы") + + conn.commit() + conn.close() + print("✅ Миграция v2.1 завершена!") + + +if __name__ == '__main__': + asyncio.run(migrate()) diff --git a/database/models.py b/database/models.py index 2cbf616..4a9bace 100644 --- a/database/models.py +++ b/database/models.py @@ -571,3 +571,26 @@ class EventParticipant(Base): def __repr__(self): return f"" + + +class InitiativeGroup(Base): + """Участники инициативной группы""" + __tablename__ = 'initiative_group' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False, unique=True) + role = Column(String(50), default='member') # admin, member, coordinator + joined_at = Column(DateTime, default=datetime.utcnow) + is_active = Column(Boolean, default=True) + notes = Column(Text, nullable=True) # Заметки о роли + + # Связи + user = relationship('User', backref='initiative_membership') + + __table_args__ = ( + Index('ix_initiative_group_user', 'user_id'), + Index('ix_initiative_group_active', 'is_active'), + ) + + def __repr__(self): + return f"" diff --git a/handlers/__init__.py b/handlers/__init__.py index 6aabf98..3341bfb 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -22,3 +22,4 @@ from .profile_history import router as profile_history_router from .achievements import router as achievements_router from .thanks import router as thanks_router from .events import router as events_router +from .initiative_group import router as initiative_group_router diff --git a/handlers/initiative_group.py b/handlers/initiative_group.py new file mode 100644 index 0000000..7dd7ea0 --- /dev/null +++ b/handlers/initiative_group.py @@ -0,0 +1,320 @@ +""" +Initiative Group — хендлеры для работы с инициативной группой +Только для администраторов +""" +import logging +from aiogram import Router, F +from aiogram.types import Message +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() + + +# ============================================================================= +# FSM для добавления в ИГ +# ============================================================================= + +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 [роль] + Роли: member, admin, coordinator + """ + args = message.text.split() + if len(args) < 2: + await message.answer( + '❌ Использование:\n' + '/ig_add <user_id> [роль]\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'✅ Пользователь добавлен в ИГ!\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') # Можно расширить для сохранения notes + + await message.answer('✅ Заметка сохранена') + await state.clear() + + +# ============================================================================= +# УДАЛЕНИЕ ИЗ ИГ +# ============================================================================= + +@router.message(Command('ig_remove')) +@admin_only +async def cmd_ig_remove(message: Message): + """ + Удалить пользователя из ИГ + Использование: /ig_remove + """ + args = message.text.split() + if len(args) < 2: + await message.answer( + '❌ Использование:\n' + '/ig_remove <user_id>', + 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'👥 Инициативная группа\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'{role_name}\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 = '📊 Статистика инициативной группы\n\n' + text += f'Всего участников: {stats["total"]}\n' + text += f'Активных: {stats["active"]}\n\n' + + if stats['by_role']: + text += 'По ролям:\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( + '📬 Рассылка для инициативной группы\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'📬 Сообщение для инициативной группы\n\n' + f'{broadcast_message}\n\n' + f'Это сообщение отправлено только участникам ИГ', + parse_mode='HTML' + ) + success_count += 1 + except Exception as e: + logger.error(f'Не удалось отправить сообщение пользователю {user_id}: {e}') + error_count += 1 + + await message.answer( + f'✅ Рассылка завершена!\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 + """ + args = message.text.split() + if len(args) < 2: + await message.answer( + '❌ Использование:\n' + '/ig_check <user_id>', + 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'✅ Пользователь в ИГ\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} не является участником ИГ') diff --git a/main.py b/main.py index 7949241..f77536c 100644 --- a/main.py +++ b/main.py @@ -121,7 +121,16 @@ 🎉 ВСЕ ФУНКЦИИ v1.x РЕАЛИЗОВАНЫ! 🎉 -ПЛАН РАЗРАБОТКИ (v2.0 — AI-Юрист): +РЕАЛИЗОВАНО В v2.1 (27.02.2026): +- ✅ Инициативная группа (отдельная сущность от админов) +- ✅ Модель InitiativeGroup в БД +- ✅ Команды: /ig_add, /ig_remove, /ig_list, /ig_stats, /ig_check +- ✅ Рассылка только для ИГ: /ig_broadcast +- ✅ Декораторы для ИГ + +🚀 НАЧАТА РАЗРАБОТКА v2.x! 🚀 + +ПЛАН РАЗРАБОТКИ (v2.2 — v2.x): См. файл DEVELOPMENT_PLAN.md и VERSIONS.md БЕКАПЫ ВЕРСИЙ: @@ -151,7 +160,7 @@ from config import ( USE_PROXY, PROXY_TYPE, PROXY_HOST, PROXY_PORT ) from database.db import init_db -from handlers import admin_router, users_router, verification_router, chat_monitor_router, security_router, apartments_router, antimat_router, phones_router, polls_router, antispam_router, ratelimit_router, schedule_router, ads_router, payments_router, multicount_router, topics_router, toxicity_router, profile_history_router, achievements_router, thanks_router, events_router +from handlers import admin_router, users_router, verification_router, chat_monitor_router, security_router, apartments_router, antimat_router, phones_router, polls_router, antispam_router, ratelimit_router, schedule_router, ads_router, payments_router, multicount_router, topics_router, toxicity_router, profile_history_router, achievements_router, thanks_router, events_router, initiative_group_router from services.scheduler import Scheduler from services.web_server import WebServerService @@ -276,6 +285,7 @@ async def main(): dp.include_router(achievements_router) dp.include_router(thanks_router) dp.include_router(events_router) + dp.include_router(initiative_group_router) # Регистрируем хуки dp.startup.register(on_startup) diff --git a/services/initiative_group.py b/services/initiative_group.py new file mode 100644 index 0000000..1699e73 --- /dev/null +++ b/services/initiative_group.py @@ -0,0 +1,189 @@ +""" +Initiative Group Service — сервис для работы с инициативной группой +""" +import logging +from datetime import datetime +from typing import Dict, List, Optional +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class InitiativeGroupService: + """Сервис для управления инициативной группой""" + + def __init__(self, session: AsyncSession): + self.session = session + + async def add_member(self, user_id: int, role: str = 'member', notes: str = None) -> bool: + """ + Добавить участника в ИГ + Returns: True если успешно + """ + from database.models import InitiativeGroup + + # Проверяем не состоит ли уже + existing = await self.session.get(InitiativeGroup, user_id) + if existing: + # Обновляем роль и статус + existing.role = role + existing.is_active = True + if notes: + existing.notes = notes + await self.session.commit() + logger.info(f"Участник {user_id} обновлён в ИГ (роль: {role})") + return True + + # Создаём нового + member = InitiativeGroup( + user_id=user_id, + role=role, + notes=notes + ) + self.session.add(member) + await self.session.commit() + + logger.info(f"Участник {user_id} добавлен в ИГ (роль: {role})") + return True + + async def remove_member(self, user_id: int) -> bool: + """ + Удалить участника из ИГ + Returns: True если успешно + """ + from database.models import InitiativeGroup + + member = await self.session.get(InitiativeGroup, user_id) + if not member: + return False + + # Не удаляем, а деактивируем + member.is_active = False + await self.session.commit() + + logger.info(f"Участник {user_id} удалён из ИГ") + return True + + async def get_members(self, active_only: bool = True) -> List[Dict]: + """ + Получить список участников ИГ + Returns: список участников + """ + from database.models import InitiativeGroup, User + + stmt = select(InitiativeGroup).join(User, InitiativeGroup.user_id == User.user_id) + + if active_only: + stmt = stmt.where(InitiativeGroup.is_active == True) + + stmt = stmt.order_by(InitiativeGroup.joined_at) + result = await self.session.execute(stmt) + members = list(result.scalars().all()) + + return [ + { + 'user_id': m.user_id, + 'role': m.role, + 'joined_at': m.joined_at, + 'is_active': m.is_active, + 'notes': m.notes, + 'name': m.user.full_name or m.user.username or f'user{m.user_id}', + 'apartment': m.user.apartment + } + for m in members + ] + + async def get_member(self, user_id: int) -> Optional[Dict]: + """ + Получить информацию об участнике ИГ + Returns: информация или None + """ + from database.models import InitiativeGroup + + member = await self.session.get(InitiativeGroup, user_id) + if not member or not member.is_active: + return None + + return { + 'user_id': member.user_id, + 'role': member.role, + 'joined_at': member.joined_at, + 'notes': member.notes + } + + async def is_member(self, user_id: int) -> bool: + """ + Проверить является ли пользователь участником ИГ + Returns: True если участник + """ + from database.models import InitiativeGroup + + member = await self.session.get(InitiativeGroup, user_id) + return member is not None and member.is_active + + async def update_role(self, user_id: int, role: str) -> bool: + """ + Обновить роль участника + Returns: True если успешно + """ + from database.models import InitiativeGroup + + member = await self.session.get(InitiativeGroup, user_id) + if not member: + return False + + member.role = role + await self.session.commit() + + logger.info(f"Роль участника {user_id} изменена на {role}") + return True + + async def get_member_ids(self, active_only: bool = True) -> List[int]: + """ + Получить список ID участников ИГ + Returns: список ID + """ + from database.models import InitiativeGroup + + stmt = select(InitiativeGroup.user_id) + if active_only: + stmt = stmt.where(InitiativeGroup.is_active == True) + + result = await self.session.execute(stmt) + return [row[0] for row in result.all()] + + async def get_stats(self) -> Dict: + """ + Получить статистику ИГ + Returns: статистика + """ + from database.models import InitiativeGroup + + # Всего участников + stmt_total = select(func.count(InitiativeGroup.id)) + result = await self.session.execute(stmt_total) + total = result.scalar() or 0 + + # Активных + stmt_active = select(func.count(InitiativeGroup.id)).where(InitiativeGroup.is_active == True) + result = await self.session.execute(stmt_active) + active = result.scalar() or 0 + + # По ролям + stmt_roles = select(InitiativeGroup.role, func.count(InitiativeGroup.id)).group_by(InitiativeGroup.role) + result = await self.session.execute(stmt_roles) + by_role = {row[0]: row[1] for row in result.all()} + + return { + 'total': total, + 'active': active, + 'by_role': by_role + } + + async def get_ig_only_broadcast_recipients(self) -> List[int]: + """ + Получить список ID для рассылки только ИГ + Returns: список user_id + """ + return await self.get_member_ids(active_only=True) diff --git a/utils/decorators.py b/utils/decorators.py index 04f85f1..8a5605f 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -26,6 +26,29 @@ def admin_only(func): return wrapper +def initiative_group_only(func): + """Только для участников инициативной группы""" + @wraps(func) + async def wrapper(message: Message, *args, **kwargs): + from database.db import AsyncSessionLocal + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + ig_service = InitiativeGroupService(session) + is_member = await ig_service.is_member(message.from_user.id) + + # Также разрешаем админам + is_admin = config.is_admin(message.from_user.id) + + if not (is_member or is_admin): + await message.answer( + '❌ Эта команда доступна только участникам инициативной группы' + ) + return + return await func(message, *args, **kwargs) + return wrapper + + def verified_only(func): """Только для верифицированных жильцов""" @wraps(func) @@ -34,7 +57,7 @@ def verified_only(func): from database.db import AsyncSessionLocal from sqlalchemy import select from database.models import User - + async with AsyncSessionLocal() as session: user = await get_user_from_db(session, message.from_user.id) if not user or not user.verified: @@ -56,3 +79,22 @@ def admin_callback_only(func): return return await func(callback, *args, **kwargs) return wrapper + + +def ig_callback_only(func): + """Только для участников ИГ (callback queries)""" + @wraps(func) + async def wrapper(callback: CallbackQuery, *args, **kwargs): + from database.db import AsyncSessionLocal + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + ig_service = InitiativeGroupService(session) + is_member = await ig_service.is_member(callback.from_user.id) + is_admin = config.is_admin(callback.from_user.id) + + if not (is_member or is_admin): + await callback.answer('❌ Только для участников ИГ', show_alert=True) + return + return await func(callback, *args, **kwargs) + return wrapper