feat: v2.1 - Инициативная группа

 Модель InitiativeGroup в БД
 Сервис initiative_group.py
 Хендлеры: /ig_add, /ig_remove, /ig_list, /ig_stats, /ig_check, /ig_broadcast
 Декораторы для ИГ
 Миграция v2.1 выполнена

📦 Бекап: backups/versions/v2.1_initiative_group_2026-02-27/

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Admin 2026-02-28 10:11:36 +00:00
parent 32f6ca8d5d
commit 19c5fdd0ac
7 changed files with 637 additions and 3 deletions

View file

@ -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())

View file

@ -571,3 +571,26 @@ class EventParticipant(Base):
def __repr__(self):
return f"<EventParticipant event={self.event_id} user={self.user_id}>"
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"<InitiativeGroup user={self.user_id} role={self.role}>"

View file

@ -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

View file

@ -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 <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') # Можно расширить для сохранения notes
await message.answer('✅ Заметка сохранена')
await state.clear()
# =============================================================================
# УДАЛЕНИЕ ИЗ ИГ
# =============================================================================
@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} не является участником ИГ')

14
main.py
View file

@ -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)

View file

@ -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)

View file

@ -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)
@ -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