domovoy_bot/services/initiative_group.py
Admin 07d7a51f10 🚨 fix: Критические исправления рассылок
- Глобальный синглтон бота (bot_instance.py) — работает из любого процесса
- Прямые HTTP запросы в send_smart_broadcast — нет ошибки Timeout context
- Исправлен joinedload в InitiativeGroupService
- Удалён дубликат route /broadcast
- Удалена страница /broadcasts — всё в одном месте с табами
- Удалён /api/broadcast/send — дублировал /api/broadcast/create
- Добавлен dry_run режим для тестирования без отправки
- Двойное подтверждение перед реальной рассылкой
- Добавлен файл КРИТИЧЕСКИЕ-ПРАВИЛА.md

Инцидент: тестовая рассылка ушла реальным пользователям

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-12 17:57:18 +04:00

195 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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 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
from sqlalchemy.orm import joinedload
# Используем joinedload для загрузки связи User
stmt = (
select(InitiativeGroup)
.options(joinedload(InitiativeGroup.user))
.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().unique().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)