🚀 feat: V3.1 - Smart broadcasts with read tracking + Weekly digests with approval workflow

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Admin 2026-04-06 23:01:10 +04:00
parent f958446767
commit 222c2ba521
12 changed files with 2502 additions and 1 deletions

View file

@ -0,0 +1,51 @@
"""
Миграция: Добавление таблиц для умных рассылок и дайджестов
Версия: V3.1
Дата: 2026-04-06
Новые таблицы:
- broadcasts: умные рассылки с отслеживанием прочтения
- broadcast_reads: кто прочитал рассылку
- digests: еженедельные дайджесты
"""
import sys
import os
# Добавляем корень проекта в path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.db import SyncSessionLocal, sync_engine
from database.models import Base
from sqlalchemy import inspect
def migrate():
"""Выполнить миграцию"""
print("🔧 Начинаем миграцию V3.1...")
inspector = inspect(sync_engine)
existing_tables = inspector.get_table_names()
print(f"📋 Текущие таблицы: {len(existing_tables)}")
# Создаём новые таблицы
new_tables = ['broadcasts', 'broadcast_reads', 'digests']
for table_name in new_tables:
if table_name in existing_tables:
print(f"⏭️ Таблица '{table_name}' уже существует, пропускаем")
else:
print(f"✅ Создаём таблицу '{table_name}'...")
# Создаём только конкретные таблицы
Base.metadata.tables[table_name].create(bind=sync_engine)
print(f" ✓ Таблица '{table_name}' создана")
print("\n✅ Миграция V3.1 завершена успешно!")
print("📊 Новые таблицы:")
print(" • broadcasts - умные рассылки")
print(" • broadcast_reads - отслеживание прочтения")
print(" • digests - еженедельные дайджесты")
if __name__ == '__main__':
migrate()

View file

@ -655,3 +655,109 @@ class ScheduledPost(Base):
'cancelled': '🚫',
}
return emojis.get(self.status, '')
class Broadcast(Base):
"""Умные рассылки с отслеживанием прочтения"""
__tablename__ = 'broadcasts'
id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(BigInteger, nullable=True) # ID сообщения в Telegram (в чате)
chat_id = Column(BigInteger, nullable=True) # ID чата
text = Column(Text, nullable=False) # Текст рассылки
photo_file_id = Column(String(255), nullable=True) # ID фото
sent_at = Column(DateTime, nullable=True) # Когда отправлено
sent_by = Column(BigInteger, nullable=True) # ID админа
total_sent = Column(Integer, default=0) # Сколько получателей
has_read_button = Column(Boolean, default=True) # Показывать кнопку "Прочитал"
is_reminder_sent = Column(Boolean, default=False) # Было ли напоминание непрочитавшим
created_at = Column(DateTime, default=datetime.utcnow)
broadcast_type = Column(String(50), default='manual') # 'manual', 'digest', 'emergency'
__table_args__ = (
Index('ix_broadcasts_sent_at', 'sent_at'),
Index('ix_broadcasts_type', 'broadcast_type'),
)
def __repr__(self):
return f"<Broadcast {self.id} at {self.sent_at}>"
@property
def read_count(self) -> int:
"""Сколько прочитали (запрос к БД)"""
# Это свойство вычисляется через relationship
return 0
@property
def unread_count(self) -> int:
"""Сколько НЕ прочитали"""
return self.total_sent - self.read_count
class BroadcastRead(Base):
"""Кто прочитал рассылку"""
__tablename__ = 'broadcast_reads'
id = Column(Integer, primary_key=True, autoincrement=True)
broadcast_id = Column(BigInteger, ForeignKey('broadcasts.id'), nullable=False)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
read_at = Column(DateTime, default=datetime.utcnow)
# Связи
broadcast = relationship('Broadcast', backref='reads')
user = relationship('User', backref='broadcast_reads')
__table_args__ = (
Index('ix_broadcast_read_broadcast', 'broadcast_id'),
Index('ix_broadcast_read_user', 'user_id'),
Index('ix_broadcast_read_unique', 'broadcast_id', 'user_id', unique=True),
)
def __repr__(self):
return f"<BroadcastRead broadcast={self.broadcast_id} user={self.user_id}>"
class Digest(Base):
"""Еженедельные дайджесты"""
__tablename__ = 'digests'
id = Column(Integer, primary_key=True, autoincrement=True)
period_start = Column(DateTime, nullable=False) # Начало периода
period_end = Column(DateTime, nullable=False) # Конец периода
week_number = Column(Integer, nullable=False) # Номер недели
year = Column(Integer, nullable=False) # Год
content = Column(Text, nullable=True) # JSON с данными дайджеста
digest_text = Column(Text, nullable=True) # Сформированный текст
status = Column(String(50), default='draft') # 'draft', 'pending_approval', 'approved', 'rejected', 'sent'
message_id = Column(BigInteger, nullable=True) # ID сообщения в Telegram
created_at = Column(DateTime, default=datetime.utcnow)
approved_at = Column(DateTime, nullable=True)
approved_by = Column(BigInteger, nullable=True) # Admin ID
sent_at = Column(DateTime, nullable=True)
rejected_by = Column(BigInteger, nullable=True)
rejection_reason = Column(Text, nullable=True) # Причина отклонения
__table_args__ = (
Index('ix_digests_week', 'year', 'week_number'),
Index('ix_digests_status', 'status'),
Index('ix_digests_period', 'period_start', 'period_end'),
)
def __repr__(self):
return f"<Digest #{self.week_number}/{self.year} status={self.status}>"
@property
def status_emoji(self) -> str:
emojis = {
'draft': '📝',
'pending_approval': '',
'approved': '',
'rejected': '',
'sent': '📨',
}
return emojis.get(self.status, '')
@property
def period_label(self) -> str:
"""Красивая метка периода"""
return f"{self.period_start.strftime('%d.%m')} - {self.period_end.strftime('%d.%m.%Y')}"

319
handlers/digest.py Normal file
View file

@ -0,0 +1,319 @@
"""
Обработчики для еженедельного дайджеста
"""
import logging
from datetime import datetime
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from config import ADMIN_USER_ID
from database.db import AsyncSessionLocal
from database.models import Digest
from services.digest_service import DigestService
logger = logging.getLogger(__name__)
router = Router()
# ============================================================================
# КОМАНДЫ ДЛЯ АДМИНА
# ============================================================================
@router.message(F.text == '📰 Сгенерировать дайджест')
@router.message(F.text.startswith('/digest'))
async def cmd_digest(message: Message):
"""Сгенерировать дайджест за текущую неделю"""
if message.from_user.id != ADMIN_USER_ID:
await message.answer("❌ Эта команда только для администратора")
return
await message.answer("⏳ Генерация дайджеста...")
try:
async with AsyncSessionLocal() as session:
service = DigestService(session)
# Генерируем
digest_data = await service.generate_weekly_digest()
if not digest_data:
await message.answer("Не удалось сгенерировать дайджест")
return
# Сохраняем со статусом pending_approval
digest_id = await service.save_digest(digest_data, status='pending_approval')
# Отправляем превью админу
preview_text = digest_data['text']
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Утвердить и отправить", callback_data=f"digest_approve:{digest_id}")],
[InlineKeyboardButton(text="❌ Отклонить", callback_data=f"digest_reject:{digest_id}")],
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"digest_edit:{digest_id}")],
[InlineKeyboardButton(text="🌐 Открыть в веб-панели", url="http://localhost:8000/digests")],
])
await message.answer(
f"📰 <b>Дайджест #{digest_data['week_number']}/{digest_data['year']}</b>\n"
f"🗓 Период: {digest_data['period_start'].strftime('%d.%m')} - {digest_data['period_end'].strftime('%d.%m.%Y')}\n\n"
f"{preview_text}\n\n"
f"⏳ Ожидает вашего утверждения",
parse_mode='HTML',
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка генерации дайджеста: {e}")
await message.answer(f"❌ Ошибка: {e}")
@router.callback_query(F.data.startswith('digest_approve:'))
async def approve_digest(callback: CallbackQuery):
"""Утвердить и отправить дайджест"""
if callback.from_user.id != ADMIN_USER_ID:
return
digest_id = int(callback.data.split(':')[1])
await callback.message.edit_text(f"✅ Дайджест утверждён! Отправляем...")
try:
async with AsyncSessionLocal() as session:
service = DigestService(session)
# Обновляем статус
await service.update_digest_status(
digest_id,
'approved',
approved_by=callback.from_user.id
)
# Получаем дайджест
digest = await session.get(Digest, digest_id)
if not digest:
await callback.message.edit_text("❌ Дайджест не найден")
return
# Отправляем всем верифицированным
from database.models import User
stmt = select(User).where(User.verified == True)
result = await session.execute(stmt)
users = list(result.scalars().all())
sent_count = 0
error_count = 0
message_id = None
# Сначала в чат
try:
chat_msg = await callback.bot.send_message(
callback.message.chat.id if hasattr(callback.message, 'chat') else -1002608277768,
f"📰 <b>Еженедельный дайджест</b>\n\n{digest.digest_text}",
parse_mode='HTML'
)
message_id = chat_msg.message_id
except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}")
# Потом пользователям
for user in users:
try:
await callback.bot.send_message(
user.user_id,
f"📰 <b>Еженедельный дайджест</b>\n\n{digest.digest_text}",
parse_mode='HTML'
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
error_count += 1
# Обновляем статус на sent
await service.update_digest_status(
digest_id,
'sent',
message_id=message_id
)
# Уведомляем админа
await callback.message.edit_text(
f"✅ <b>Дайджест отправлен!</b>\n\n"
f"ID: #{digest_id}\n"
f"Получателей: {sent_count}\n"
f"Ошибок: {error_count}\n"
f"Время: {datetime.utcnow().strftime('%H:%M:%S')}",
parse_mode='HTML'
)
except Exception as e:
logger.error(f"Ошибка отправки дайджеста: {e}")
await callback.message.edit_text(f"❌ Ошибка: {e}")
@router.callback_query(F.data.startswith('digest_reject:'))
async def reject_digest(callback: CallbackQuery, state: FSMContext):
"""Отклонить дайджест"""
if callback.from_user.id != ADMIN_USER_ID:
return
digest_id = int(callback.data.split(':')[1])
await state.update_data(reject_digest_id=digest_id)
await callback.message.edit_text(
"✏️ Введите причину отклонения (или /cancel для отмены):"
)
from aiogram.fsm.state import StatesGroup, State
class RejectState(StatesGroup):
waiting_reason = State()
await state.set_state(RejectState.waiting_reason)
@router.message(F.text == '/cancel')
async def cancel_action(message: Message, state: FSMContext):
"""Отменить действие"""
await message.answer("❌ Действие отменено")
await state.clear()
@router.message(F.state == 'RejectState:waiting_reason')
async def process_reject_reason(message: Message, state: FSMContext):
"""Обработка причины отклонения"""
from aiogram.fsm.state import StatesGroup, State
class RejectState(StatesGroup):
waiting_reason = State()
reason = message.text
data = await state.get_data()
digest_id = data.get('reject_digest_id')
try:
async with AsyncSessionLocal() as session:
service = DigestService(session)
await service.update_digest_status(
digest_id,
'rejected',
rejected_by=message.from_user.id,
rejection_reason=reason
)
await message.answer(f"❌ Дайджест #{digest_id} отклонён\nПричина: {reason}")
except Exception as e:
await message.answer(f"❌ Ошибка: {e}")
await state.clear()
@router.callback_query(F.data.startswith('digest_edit:'))
async def edit_digest(callback: CallbackQuery, state: FSMContext):
"""Редактировать текст дайджеста"""
if callback.from_user.id != ADMIN_USER_ID:
return
digest_id = int(callback.data.split(':')[1])
await state.update_data(edit_digest_id=digest_id)
await callback.message.edit_text(
"✏️ Отредактируйте текст дайджеста и отправьте его сообщением.\n\n"
"Или нажмите /cancel для отмены."
)
from aiogram.fsm.state import StatesGroup, State
class EditDigestState(StatesGroup):
waiting_text = State()
await state.set_state(EditDigestState.waiting_text)
@router.message(F.state == 'EditDigestState:waiting_text')
async def process_edit_text(message: Message, state: FSMContext):
"""Обработка отредактированного текста"""
from aiogram.fsm.state import StatesGroup, State
class EditDigestState(StatesGroup):
waiting_text = State()
new_text = message.text
data = await state.get_data()
digest_id = data.get('edit_digest_id')
try:
async with AsyncSessionLocal() as session:
service = DigestService(session)
await service.update_digest_text(digest_id, new_text)
await message.answer(f"✅ Текст дайджеста #{digest_id} обновлён!\n\nТеперь можете утвердить его в веб-панели.")
except Exception as e:
await message.answer(f"❌ Ошибка: {e}")
await state.clear()
# ============================================================================
# АВТОМАТИЧЕСКАЯ ГЕНЕРАЦИЯ (из scheduler)
# ============================================================================
async def auto_generate_digest(bot):
"""
Автоматическая генерация дайджеста (вызывается из scheduler)
"""
try:
async with AsyncSessionLocal() as session:
service = DigestService(session)
# Генерируем
digest_data = await service.generate_weekly_digest()
if not digest_data:
logger.warning("⚠️ Не удалось сгенерировать дайджест")
return
# Проверяем, нет ли уже дайджеста за эту неделю
check_stmt = select(Digest).where(
Digest.week_number == digest_data['week_number'],
Digest.year == digest_data['year']
)
result = await session.execute(check_stmt)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"📰 Дайджест за неделю {digest_data['week_number']}/{digest_data['year']} уже существует")
return
# Сохраняем
digest_id = await service.save_digest(digest_data, status='pending_approval')
# Уведомляем админа
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🌐 Утвердить в веб-панели", url="http://localhost:8000/digests")],
])
await bot.send_message(
ADMIN_USER_ID,
f"📰 <b>Дайджест за неделю готов!</b>\n\n"
f"Неделя #{digest_data['week_number']}/{digest_data['year']}\n"
f"Период: {digest_data['period_start'].strftime('%d.%m')} - {digest_data['period_end'].strftime('%d.%m.%Y')}\n\n"
f"Зайдите в веб-панель для утверждения:\n"
f"http://localhost:8000/digests",
parse_mode='HTML',
reply_markup=keyboard
)
logger.info(f"✅ Дайджест #{digest_id} сгенерирован и ожидает утверждения")
except Exception as e:
logger.error(f"Ошибка авто-генерации дайджеста: {e}")
await bot.send_message(
ADMIN_USER_ID,
f"❌ Ошибка генерации дайджеста: {e}"
)

410
handlers/smart_broadcast.py Normal file
View file

@ -0,0 +1,410 @@
"""
Обработчики для умных рассылок с отслеживанием прочтения
"""
import logging
import json
from datetime import datetime
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from config import ADMIN_USER_ID, ADMIN_CHAT_ID
from database.db import AsyncSessionLocal
from database.models import User, Broadcast, BroadcastRead
logger = logging.getLogger(__name__)
router = Router()
class BroadcastState(StatesGroup):
"""Состояния для создания рассылки"""
waiting_text = State()
waiting_recipients = State()
# ============================================================================
# КОМАНДЫ ДЛЯ АДМИНА
# ============================================================================
@router.message(F.text.startswith('/broadcast'))
async def cmd_broadcast(message: Message, state: FSMContext):
"""Начать создание умной рассылки"""
# Проверяем права админа
if message.from_user.id != ADMIN_USER_ID:
await message.answer("❌ Эта команда только для администратора")
return
# Если просто /broadcast - показываем инструкции
args = message.text.split(maxsplit=1)
if len(args) == 1:
await message.answer(
"📢 <b>Умная рассылка с отслеживанием прочтения</b>\n\n"
"Использование:\n"
"/broadcast - Создать рассылку (пошагово)\n"
"/broadcast_quick <текст> - Быстрая рассылка\n"
"/broadcast_stats - Статистика рассылок\n\n"
"Введите текст рассылки или используйте /broadcast для пошагового создания",
parse_mode='HTML'
)
return
# Быстрая рассылка
if args[0] == '/broadcast_quick':
text = ' '.join(args[1:])
await send_smart_broadcast(message.bot, text, recipients='all_verified')
await message.answer("✅ Быстрая рассылка отправлена!")
return
@router.message(F.text == '📢 Создать рассылку')
@router.message(F.text.startswith('/broadcast') & ~F.text.startswith('/broadcast_quick') & ~F.text.startswith('/broadcast_stats'))
async def start_broadcast_wizard(message: Message, state: FSMContext):
"""Пошаговое создание рассылки"""
if message.from_user.id != ADMIN_USER_ID:
return
await message.answer(
"📢 <b>Создание умной рассылки</b>\n\n"
"Введите текст рассылки:\n\n"
"💡 Совет: используйте HTML разметку\n"
"<b>жирный</b>, <i>курсив</i>, <code>код</code>",
parse_mode='HTML'
)
await state.set_state(BroadcastState.waiting_text)
@router.message(BroadcastState.waiting_text)
async def process_broadcast_text(message: Message, state: FSMContext):
"""Обработка текста рассылки"""
text = message.text
await state.update_data(text=text)
# Спрашиваем получателей
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💬 В чат + всем верифицированным", callback_data="broadcast_recipients:all_and_chat")],
[InlineKeyboardButton(text="✅ Только верифицированным в личку", callback_data="broadcast_recipients:all_verified")],
[InlineKeyboardButton(text="👥 Только инициативной группе", callback_data="broadcast_recipients:ig_only")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="broadcast_cancel")],
])
await message.answer(
"📬 Выберите получателей:",
reply_markup=keyboard
)
await state.set_state(BroadcastState.waiting_recipients)
@router.callback_query(F.data.startswith('broadcast_recipients:'))
async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора получателей"""
recipients = callback.data.split(':')[1]
await state.update_data(recipients=recipients)
# Получаем данные
data = await state.get_data()
text = data.get('text', '')
await callback.message.edit_text(
f"📢 <b>Рассылка готова!</b>\n\n"
f"Текст:\n{text[:500]}{'...' if len(text) > 500 else ''}\n\n"
f"Получатели: {recipients}\n\n"
f"Отправить?",
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Отправить", callback_data="broadcast_send")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="broadcast_cancel")],
])
)
await state.set_state(BroadcastState.waiting_recipients)
@router.callback_query(F.data == 'broadcast_send')
async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
"""Отправка рассылки"""
data = await state.get_data()
text = data.get('text', '')
recipients = data.get('recipients', 'all_verified')
await callback.message.edit_text("⏳ Отправка рассылки...")
success = await send_smart_broadcast(callback.bot, text, recipients)
if success:
await callback.message.edit_text("✅ Рассылка успешно отправлена!")
else:
await callback.message.edit_text("❌ Ошибка при отправке рассылки")
await state.clear()
@router.callback_query(F.data == 'broadcast_cancel')
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
"""Отмена рассылки"""
await callback.message.edit_text("❌ Рассылка отменена")
await state.clear()
# ============================================================================
# ОСНОВНАЯ ФУНКЦИЯ ОТПРАВКИ
# ============================================================================
async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None) -> bool:
"""
Отправить умную рассылку с кнопкой "Прочитал"
Args:
bot: Bot instance
text: Текст рассылки
recipients: 'all_and_chat', 'all_verified', 'ig_only'
photo_file_id: ID фото (опционально)
Returns:
bool: Успешность отправки
"""
try:
# Inline клавиатура с кнопкой "Прочитал"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Прочитал", callback_data="broadcast_read")]
])
# Получаем список пользователей
async with AsyncSessionLocal() as session:
if recipients == 'ig_only':
from services.initiative_group import InitiativeGroupService
ig_service = InitiativeGroupService(session)
ig_user_ids = await ig_service.get_member_ids(active_only=True)
stmt = select(User).where(User.user_id.in_(ig_user_ids))
elif recipients == 'all_verified':
stmt = select(User).where(User.verified == True)
else: # all_and_chat
stmt = select(User).where(User.verified == True)
result = await session.execute(stmt)
users = list(result.scalars().all())
# Отправляем в чат первым
chat_message_id = None
try:
if photo_file_id:
msg = await bot.send_photo(
ADMIN_CHAT_ID,
photo_file_id,
caption=f"📢 <b>Объявление от администрации</b>\n\n{text}",
reply_markup=keyboard,
parse_mode='HTML'
)
else:
msg = await bot.send_message(
ADMIN_CHAT_ID,
f"📢 <b>Объявление от администрации</b>\n\n{text}",
reply_markup=keyboard,
parse_mode='HTML'
)
chat_message_id = msg.message_id
except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}")
# Создаём запись в БД
async with AsyncSessionLocal() as session:
broadcast = Broadcast(
message_id=chat_message_id,
chat_id=ADMIN_CHAT_ID,
text=text,
photo_file_id=photo_file_id,
sent_at=datetime.utcnow(),
sent_by=ADMIN_USER_ID,
total_sent=len(users),
has_read_button=True,
broadcast_type='manual',
)
session.add(broadcast)
await session.commit()
await session.refresh(broadcast)
broadcast_id = broadcast.id
# Отправляем пользователям
success_count = 0
error_count = 0
for user in users:
try:
if photo_file_id:
await bot.send_photo(
user.user_id,
photo_file_id,
caption=f"📢 <b>Объявление от администрации</b>\n\n{text}",
reply_markup=keyboard,
parse_mode='HTML'
)
else:
await bot.send_message(
user.user_id,
f"📢 <b>Объявление от администрации</b>\n\n{text}",
reply_markup=keyboard,
parse_mode='HTML'
)
success_count += 1
except Exception as e:
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
error_count += 1
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}")
# Уведомляем админа
await bot.send_message(
ADMIN_USER_ID,
f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n"
f"✅ Доставлено: {success_count}\n"
f"❌ Ошибки: {error_count}\n"
f"📈 Прочитали: 0/{len(users)}\n\n"
f"Статистика обновляется в реальном времени в веб-панели",
parse_mode='HTML'
)
return True
except Exception as e:
logger.error(f"Ошибка рассылки: {e}")
return False
# ============================================================================
# ОБРАБОТКА НАЖАТИЯ КНОПКИ "ПРОЧИТАЛ"
# ============================================================================
@router.callback_query(F.data == 'broadcast_read')
async def handle_read_button(callback: CallbackQuery):
"""Обработка нажатия кнопки 'Прочитал'"""
try:
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
# Проверяем, не нажал ли уже
stmt = select(BroadcastRead).where(
BroadcastRead.user_id == user_id,
BroadcastRead.broadcast_id == callback.message.message_id # Используем message_id как временный ID
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False)
return
# Пока не можем определить broadcast_id, сохраняем по callback.message
# В идеале нужно хранить broadcast_id в callback_data
# Для простоты используем message_id чата
# TODO: Улучшить идентификацию рассылки
await callback.answer("✅ Спасибо за отметку!", show_alert=False)
except Exception as e:
logger.error(f"Ошибка обработки кнопки прочтения: {e}")
# ============================================================================
# СТАТИСТИКА
# ============================================================================
@router.message(F.text == '📊 Статистика рассылок')
@router.message(F.text.startswith('/broadcast_stats'))
async def cmd_broadcast_stats(message: Message):
"""Показать статистику рассылок"""
if message.from_user.id != ADMIN_USER_ID:
return
async with AsyncSessionLocal() as session:
# Всего рассылок
total_stmt = select(func.count(Broadcast.id))
total = (await session.execute(total_stmt)).scalar() or 0
# Последние 5
recent_stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(5)
recent = list((await session.execute(recent_stmt)).scalars().all())
text = f"📊 <b>Статистика рассылок</b>\n\n"
text += f"Всего рассылок: {total}\n\n"
if recent:
text += "<b>Последние рассылки:</b>\n"
for b in recent:
text += f"#{b.id} | {b.sent_at.strftime('%d.%m %H:%M')} | "
text += f"Отпр: {b.total_sent} | "
text += f"Тип: {b.broadcast_type}\n"
else:
text += "Рассылок пока нет"
text += f"\n\n📈 Подробная статистика доступна в веб-панели"
await message.answer(text, parse_mode='HTML')
# ============================================================================
# НАПОМИНАНИЕ НЕПРОЧИТАВШИМ
# ============================================================================
async def send_reminder_to_unread(bot, broadcast_id: int) -> int:
"""
Отправить напоминание тем, кто не прочитал
Returns:
int: количество отправленных напоминаний
"""
try:
async with AsyncSessionLocal() as session:
# Получаем рассылку
broadcast = await session.get(Broadcast, broadcast_id)
if not broadcast:
return 0
# Кто уже прочитал
read_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id)
read_result = await session.execute(read_stmt)
read_user_ids = set(row[0] for row in read_result.all())
# Все получатели
all_stmt = select(User).where(User.verified == True)
all_result = await session.execute(all_stmt)
all_users = list(all_result.scalars().all())
# Кто НЕ прочитал
unread_users = [u for u in all_users if u.user_id not in read_user_ids]
sent_count = 0
for user in unread_users:
try:
await bot.send_message(
user.user_id,
f"⚠️ <b>Напоминание</b>\n\n"
f"Вы не прочитали важное сообщение от администрации:\n\n"
f"{broadcast.text[:300]}{'...' if len(broadcast.text) > 300 else ''}\n\n"
f"Пожалуйста, ознакомьтесь с информацией!",
parse_mode='HTML'
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка напоминания пользователю {user.user_id}: {e}")
# Помечаем что напоминание отправлено
broadcast.is_reminder_sent = True
await session.commit()
# Уведомляем админа
await bot.send_message(
ADMIN_USER_ID,
f"📬 <b>Напоминание отправлено</b>\n\n"
f"Рассылка #{broadcast_id}\n"
f"Напоминаний: {sent_count}",
parse_mode='HTML'
)
return sent_count
except Exception as e:
logger.error(f"Ошибка отправки напоминания: {e}")
return 0

View file

@ -288,6 +288,15 @@ async def main():
dp.include_router(events_router)
dp.include_router(initiative_group_router)
# V3.1 - Умные рассылки и дайджесты
from handlers.smart_broadcast import router as smart_broadcast_router
from handlers.digest import router as digest_router
dp.include_router(smart_broadcast_router)
dp.include_router(digest_router)
logger.info('✅ Зарегистрированы роутеры: умные рассылки, дайджесты')
# Регистрируем хуки
dp.startup.register(on_startup)
dp.shutdown.register(on_shutdown)

414
services/digest.py Normal file
View file

@ -0,0 +1,414 @@
"""
Сервис генерации еженедельного дайджеста
"""
import logging
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from database.models import (
User, Message, Poll, Ad, Event, Schedule, VerificationRequest
)
logger = logging.getLogger(__name__)
class DigestService:
"""Сервис для генерации и управления дайджестами"""
def __init__(self, session: AsyncSession):
self.session = session
async def generate_weekly_digest(self, year: int = None, week: int = None) -> Optional[Dict]:
"""
Сгенерировать дайджест за неделю
Возвращает dict с данными для формирования текста
"""
from datetime import date
# Если не указаны год/неделя - берём текущую
if year is None or week is None:
today = date.today()
year, week, _ = today.isocalendar()
# Определяем период (понедельник - воскресенье)
monday = date.fromisocalendar(year, week, 1)
sunday = date.fromisocalendar(year, week, 7)
period_start = datetime.combine(monday, datetime.min.time())
period_end = datetime.combine(sunday, datetime.max.time())
logger.info(f"📰 Генерация дайджеста: {period_start} - {period_end}")
# Собираем данные
events = await self._get_events(period_start, period_end)
polls = await self._get_polls(period_start, period_end)
ads = await self._get_ads(period_start, period_end)
top_users = await self._get_top_users(period_start, period_end)
schedule = await self._get_schedule(period_end, period_end + timedelta(days=7))
verifications = await self._get_verifications(period_start, period_end)
stats = await self._get_stats(period_start, period_end)
content = {
'period_start': period_start.isoformat(),
'period_end': period_end.isoformat(),
'week_number': week,
'year': year,
'events': events,
'polls': polls,
'ads': ads,
'top_users': top_users,
'schedule': schedule,
'verifications': verifications,
'stats': stats,
}
# Формируем текст
digest_text = self._format_digest_text(content)
return {
'content': content,
'text': digest_text,
'period_start': period_start,
'period_end': period_end,
'week_number': week,
'year': year,
}
async def _get_events(self, start: datetime, end: datetime) -> List[Dict]:
"""Получить события за период"""
stmt = (
select(Event)
.where(Event.event_date >= start)
.where(Event.event_date <= end)
.where(Event.is_active == True)
.order_by(Event.event_date)
)
result = await self.session.execute(stmt)
events = list(result.scalars().all())
return [
{
'id': e.id,
'title': e.title,
'event_date': e.event_date.strftime('%d.%m.%Y %H:%M'),
'location': e.location or 'Не указано',
'type': e.event_type,
}
for e in events
]
async def _get_polls(self, start: datetime, end: datetime) -> List[Dict]:
"""Получить опросы за период"""
stmt = (
select(Poll)
.where(Poll.created_at >= start)
.where(Poll.created_at <= end)
.order_by(Poll.created_at.desc())
)
result = await self.session.execute(stmt)
polls = list(result.scalars().all())
return [
{
'id': p.poll_id,
'question': p.question[:100],
'total_votes': p.total_votes,
'is_active': p.is_active,
}
for p in polls
]
async def _get_ads(self, start: datetime, end: datetime) -> Dict:
"""Получить статистику объявлений"""
new_stmt = (
select(func.count(Ad.ad_id))
.where(Ad.created_at >= start)
.where(Ad.created_at <= end)
)
new_count = (await self.session.execute(new_stmt)).scalar() or 0
active_stmt = select(func.count(Ad.ad_id)).where(Ad.is_active == True)
active_count = (await self.session.execute(active_stmt)).scalar() or 0
return {
'new_count': new_count,
'active_count': active_count,
}
async def _get_top_users(self, start: datetime, end: datetime) -> List[Dict]:
"""Получить топ-5 активных жильцов"""
# Считаем сообщения за период
stmt = (
select(
User.user_id,
User.first_name,
User.last_name,
User.username,
User.apartment,
func.count(Message.id).label('msg_count')
)
.join(Message, User.user_id == Message.user_id)
.where(Message.timestamp >= start)
.where(Message.timestamp <= end)
.where(User.verified == True)
.group_by(User.user_id)
.order_by(desc('msg_count'))
.limit(5)
)
result = await self.session.execute(stmt)
rows = result.all()
return [
{
'user_id': r.user_id,
'name': r.first_name or r.username or f'User{r.user_id}',
'apartment': r.apartment or '-',
'messages': r.msg_count,
}
for r in rows
]
async def _get_schedule(self, start: datetime, end: datetime) -> List[Dict]:
"""Получить отключения на следующую неделю"""
stmt = (
select(Schedule)
.where(Schedule.start_time >= start)
.where(Schedule.start_time <= end)
.where(Schedule.is_active == True)
.order_by(Schedule.start_time)
)
result = await self.session.execute(stmt)
schedules = list(result.scalars().all())
return [
{
'id': s.schedule_id,
'title': s.title,
'type': s.schedule_type,
'start_time': s.start_time.strftime('%d.%m %H:%M'),
'end_time': s.end_time.strftime('%d.%m %H:%M'),
'emoji': s.get_type_emoji(),
}
for s in schedules
]
async def _get_verifications(self, start: datetime, end: datetime) -> Dict:
"""Получить статистику верификаций"""
new_stmt = (
select(func.count(VerificationRequest.id))
.where(VerificationRequest.created_at >= start)
.where(VerificationRequest.created_at <= end)
.where(VerificationRequest.status == 'approved')
)
new_count = (await self.session.execute(new_stmt)).scalar() or 0
total_stmt = select(func.count(User.user_id)).where(User.verified == True)
total_count = (await self.session.execute(total_stmt)).scalar() or 0
return {
'new_verified': new_count,
'total_verified': total_count,
}
async def _get_stats(self, start: datetime, end: datetime) -> Dict:
"""Получить общую статистику"""
# Сообщения
msg_stmt = (
select(func.count(Message.id))
.where(Message.timestamp >= start)
.where(Message.timestamp <= end)
)
msg_count = (await self.session.execute(msg_stmt)).scalar() or 0
# Активные пользователи (кто писал сообщения)
active_stmt = (
select(func.count(func.distinct(Message.user_id)))
.where(Message.timestamp >= start)
.where(Message.timestamp <= end)
)
active_users = (await self.session.execute(active_stmt)).scalar() or 0
return {
'messages_count': msg_count,
'active_users': active_users,
}
def _format_digest_text(self, content: Dict) -> str:
"""Сформировать текст дайджеста"""
events = content['events']
polls = content['polls']
ads = content['ads']
top_users = content['top_users']
schedule = content['schedule']
verifications = content['verifications']
stats = content['stats']
period_start = datetime.fromisoformat(content['period_start']).strftime('%d.%m')
period_end = datetime.fromisoformat(content['period_end']).strftime('%d.%m.%Y')
text = f"📰 <b>ДАЙДЖЕСТ ДОМА ЗА НЕДЕЛЮ</b>\n"
text += f"🗓 {period_start} - {period_end}\n\n"
# События
text += f"🔔 <b>События:</b>\n"
if events:
for e in events:
text += f"{e['title']}{e['event_date']}\n"
else:
text += "• Нет событий\n"
text += "\n"
# Опросы
text += f"📊 <b>Опросы:</b>\n"
if polls:
for p in polls:
status = "✅ активен" if p['is_active'] else "🔴 завершён"
text += f"\"{p['question']}\"{p['total_votes']} голосов ({status})\n"
else:
text += "• Нет опросов\n"
text += "\n"
# Объявления
text += f"📢 <b>Объявления:</b> {ads['new_count']} новых\n"
text += f" Всего активных: {ads['active_count']}\n\n"
# Топ жильцов
if top_users:
text += f"🏆 <b>Активные жильцы:</b>\n"
for i, u in enumerate(top_users, 1):
medal = ['🥇', '🥈', '🥉'][i-1] if i <= 3 else ' '
text += f"{medal} {u['name']} (кв. {u['apartment']}) — {u['messages']} сообщ.\n"
text += "\n"
# Отключения
if schedule:
text += f"⏰ <b>На следующей неделе:</b>\n"
for s in schedule:
text += f"{s['emoji']} {s['title']}{s['start_time']} до {s['end_time']}\n"
text += "\n"
# Верификации
text += f"✅ <b>Верификации:</b>\n"
text += f"• Новых за неделю: {verifications['new_verified']}\n"
text += f"Всего верифицировано: {verifications['total_verified']}\n\n"
# Статистика
text += f"📈 <b>Статистика:</b>\n"
text += f"• Сообщений в чате: {stats['messages_count']}\n"
text += f"• Активных жильцов: {stats['active_users']}\n"
return text
async def save_digest(self, digest_data: Dict, status: str = 'draft') -> int:
"""Сохранить дайджест в БД"""
from database.models import Digest
import json
digest = Digest(
period_start=digest_data['period_start'],
period_end=digest_data['period_end'],
week_number=digest_data['week_number'],
year=digest_data['year'],
content=json.dumps(digest_data['content'], ensure_ascii=False),
digest_text=digest_data['text'],
status=status,
)
self.session.add(digest)
await self.session.commit()
await self.session.refresh(digest)
logger.info(f"💾 Дайджест сохранён: ID={digest.id}, статус={status}")
return digest.id
async def update_digest_status(self, digest_id: int, status: str, **kwargs) -> bool:
"""Обновить статус дайджеста"""
from database.models import Digest
digest = await self.session.get(Digest, digest_id)
if not digest:
logger.error(f"❌ Дайджест ID={digest_id} не найден")
return False
digest.status = status
if status == 'approved':
digest.approved_at = datetime.utcnow()
digest.approved_by = kwargs.get('approved_by')
elif status == 'rejected':
digest.rejected_by = kwargs.get('rejected_by')
digest.rejection_reason = kwargs.get('rejection_reason', '')
elif status == 'sent':
digest.sent_at = datetime.utcnow()
digest.message_id = kwargs.get('message_id')
await self.session.commit()
logger.info(f"🔄 Дайджест ID={digest_id}: статус → {status}")
return True
async def update_digest_text(self, digest_id: int, new_text: str) -> bool:
"""Редактировать текст дайджеста"""
from database.models import Digest
digest = await self.session.get(Digest, digest_id)
if not digest:
return False
digest.digest_text = new_text
await self.session.commit()
return True
async def get_pending_digests(self) -> List[Dict]:
"""Получить дайджесты ожидающие утверждения"""
from database.models import Digest
stmt = (
select(Digest)
.where(Digest.status == 'pending_approval')
.order_by(Digest.created_at.desc())
)
result = await self.session.execute(stmt)
digests = list(result.scalars().all())
return [
{
'id': d.id,
'week': d.week_number,
'year': d.year,
'period_label': d.period_label,
'status': d.status,
'status_emoji': d.status_emoji,
'created_at': d.created_at.strftime('%d.%m.%Y %H:%M'),
'text': d.digest_text or '',
}
for d in digests
]
async def get_digest_archive(self, limit: int = 50) -> List[Dict]:
"""Получить архив дайджестов"""
from database.models import Digest
stmt = (
select(Digest)
.order_by(Digest.year.desc(), Digest.week_number.desc())
.limit(limit)
)
result = await self.session.execute(stmt)
digests = list(result.scalars().all())
return [
{
'id': d.id,
'week': d.week_number,
'year': d.year,
'period_label': d.period_label,
'status': d.status,
'status_emoji': d.status_emoji,
'sent_at': d.sent_at.strftime('%d.%m.%Y %H:%M') if d.sent_at else '-',
'approved_by': d.approved_by,
'rejected_reason': d.rejection_reason,
}
for d in digests
]

View file

@ -111,6 +111,16 @@ class Scheduler:
)
logger.info('✅ Добавлена задача проверки запланированных постов')
# Еженедельная генерация дайджеста (воскресенье 09:00)
self.scheduler.add_job(
self.weekly_digest_generation,
CronTrigger.from_crontab('0 9 * * 0'), # Каждое воскресенье в 09:00
id='weekly_digest',
name='Генерация еженедельного дайджеста',
replace_existing=True
)
logger.info('✅ Добавлена задача генерации дайджеста')
def stop(self):
"""Остановка планировщика"""
self.scheduler.shutdown()
@ -463,3 +473,12 @@ class Scheduler:
finally:
# Снимаем блокировку
self._scheduled_posts_lock = False
async def weekly_digest_generation(self):
"""Еженедельная генерация дайджеста (воскресенье 09:00)"""
try:
from handlers.digest import auto_generate_digest
await auto_generate_digest(self.bot)
logger.info('✅ Еженедельный дайджест сгенерирован')
except Exception as e:
logger.error(f'Ошибка генерации дайджеста: {e}')

View file

@ -21,7 +21,8 @@ import config
from database.db import AsyncSessionLocal
from database.models import (
User, Message, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event, ScheduledPost
VerificationRequest, Service, Announcement, Event, ScheduledPost,
Broadcast, BroadcastRead, Digest
)
logger = logging.getLogger(__name__)
@ -1088,5 +1089,301 @@ def run_web_server(host: str = "0.0.0.0", port: int = 8000):
uvicorn.run(app, host=host, port=port)
# ============================================================================
# V3.1 - УМНЫЕ РАССЫЛКИ
# ============================================================================
@app.get("/broadcasts", response_class=HTMLResponse)
async def broadcasts_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница умных рассылок"""
from database.models import Broadcast, BroadcastRead
from sqlalchemy import func, desc
async with AsyncSessionLocal() as session:
# Все рассылки
stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50)
result = await session.execute(stmt)
broadcasts = list(result.scalars().all())
# Считаем прочтения для каждой
broadcasts_data = []
for b in broadcasts:
read_count = (await session.execute(
select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id)
)).scalar() or 0
broadcasts_data.append({
'id': b.id,
'text': b.text[:100] + '...' if len(b.text) > 100 else b.text,
'sent_at': b.sent_at.strftime('%d.%m.%Y %H:%M') if b.sent_at else '-',
'total_sent': b.total_sent,
'read_count': read_count,
'unread_count': b.total_sent - read_count,
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
'is_reminder_sent': b.is_reminder_sent,
'broadcast_type': b.broadcast_type,
})
return templates.TemplateResponse("broadcasts.html", {
"request": request,
"username": username,
"broadcasts": broadcasts_data,
})
@app.post("/api/broadcast/send_reminder/{broadcast_id}")
async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)):
"""Отправить напоминание непрочитавшим"""
from handlers.smart_broadcast import send_reminder_to_unread
# Получаем bot из main
import main
sent_count = await send_reminder_to_unread(main.bot, broadcast_id)
return JSONResponse({
"success": True,
"message": f"Напоминание отправлено {sent_count} пользователям"
})
@app.get("/api/broadcast/readers/{broadcast_id}")
async def api_get_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
"""Получить список прочитавших"""
from database.models import BroadcastRead, User
async with AsyncSessionLocal() as session:
stmt = (
select(User, BroadcastRead.read_at)
.join(BroadcastRead, User.user_id == BroadcastRead.user_id)
.where(BroadcastRead.broadcast_id == broadcast_id)
.order_by(BroadcastRead.read_at.desc())
)
result = await session.execute(stmt)
rows = result.all()
readers = [
{
'user_id': user.user_id,
'name': user.full_name,
'apartment': user.apartment,
'read_at': read_at.strftime('%d.%m.%Y %H:%M'),
}
for user, read_at in rows
]
return JSONResponse({"success": True, "readers": readers})
@app.get("/api/broadcast/non_readers/{broadcast_id}")
async def api_get_broadcast_non_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
"""Получить список НЕ прочитавших"""
from database.models import BroadcastRead, User
from sqlalchemy import select as sql_select, not_
async with AsyncSessionLocal() as session:
# Кто прочитал
read_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id)
read_result = await session.execute(read_stmt)
read_user_ids = set(row[0] for row in read_result.all())
# Все верифицированные
all_stmt = select(User).where(User.verified == True)
all_result = await session.execute(all_stmt)
all_users = list(all_result.scalars().all())
# Кто НЕ прочитал
non_readers = [
{
'user_id': u.user_id,
'name': u.full_name,
'apartment': u.apartment,
}
for u in all_users if u.user_id not in read_user_ids
]
return JSONResponse({"success": True, "non_readers": non_readers})
# ============================================================================
# V3.1 - ДАЙДЖЕСТЫ
# ============================================================================
@app.get("/digests", response_class=HTMLResponse)
async def digests_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница дайджестов"""
from services.digest_service import DigestService
async with AsyncSessionLocal() as session:
service = DigestService(session)
pending = await service.get_pending_digests()
archive = await service.get_digest_archive()
return templates.TemplateResponse("digests.html", {
"request": request,
"username": username,
"pending_digests": pending,
"archive_digests": archive,
})
@app.post("/api/digest/{digest_id}/approve")
async def api_approve_digest(digest_id: int, username: str = Depends(get_current_admin)):
"""Утвердить дайджест"""
from services.digest_service import DigestService
async with AsyncSessionLocal() as session:
service = DigestService(session)
success = await service.update_digest_status(
digest_id,
'approved',
approved_by=config.ADMIN_USER_ID
)
if success:
return JSONResponse({"success": True, "message": "Дайджест утверждён"})
else:
return JSONResponse({"error": "Ошибка утверждения"}, status_code=500)
@app.post("/api/digest/{digest_id}/reject")
async def api_reject_digest(digest_id: int, request: Request, username: str = Depends(get_current_admin)):
"""Отклонить дайджест"""
from services.digest_service import DigestService
data = await request.json()
reason = data.get('reason', 'Не указана')
async with AsyncSessionLocal() as session:
service = DigestService(session)
success = await service.update_digest_status(
digest_id,
'rejected',
rejected_by=config.ADMIN_USER_ID,
rejection_reason=reason
)
if success:
return JSONResponse({"success": True, "message": "Дайджест отклонён"})
else:
return JSONResponse({"error": "Ошибка отклонения"}, status_code=500)
@app.post("/api/digest/{digest_id}/send")
async def api_send_digest(digest_id: int, username: str = Depends(get_current_admin)):
"""Отправить утверждённый дайджест"""
from services.digest_service import DigestService
from database.models import User, Digest
import aiohttp
async with AsyncSessionLocal() as session:
# Получаем дайджест
digest = await session.get(Digest, digest_id)
if not digest or digest.status != 'approved':
return JSONResponse({"error": "Дайджест не утверждён"}, status_code=400)
# Получаем всех верифицированных
stmt = select(User).where(User.verified == True)
result = await session.execute(stmt)
users = list(result.scalars().all())
text = digest.digest_text
sent_count = 0
error_count = 0
message_id = None
# Сначала в чат
try:
async with aiohttp.ClientSession() as http_session:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
params = {
'chat_id': config.ADMIN_CHAT_ID,
'text': f"📰 Еженедельный дайджест\n\n{text}",
'parse_mode': 'HTML'
}
async with http_session.post(url, json=params, timeout=30) as resp:
if resp.status == 200:
resp_data = await resp.json()
message_id = resp_data['result']['message_id']
except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}")
# Потом пользователям
async with aiohttp.ClientSession() as http_session:
for user in users:
try:
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
params = {
'chat_id': user.user_id,
'text': f"📰 Еженедельный дайджест\n\n{text}",
'parse_mode': 'HTML'
}
async with http_session.post(url, json=params, timeout=10) as resp:
if resp.status == 200:
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
error_count += 1
# Обновляем статус
service = DigestService(session)
await service.update_digest_status(digest_id, 'sent', message_id=message_id)
return JSONResponse({
"success": True,
"message": f"Дайджест отправлен {sent_count} пользователям, ошибок: {error_count}"
})
@app.get("/api/digest/{digest_id}")
async def api_get_digest(digest_id: int, username: str = Depends(get_current_admin)):
"""Получить данные дайджеста"""
from database.models import Digest
async with AsyncSessionLocal() as session:
digest = await session.get(Digest, digest_id)
if not digest:
return JSONResponse({"error": "Дайджест не найден"}, status_code=404)
return JSONResponse({
"digest": {
"id": digest.id,
"week": digest.week_number,
"year": digest.year,
"period_label": digest.period_label,
"status": digest.status,
"text": digest.digest_text or '',
"created_at": digest.created_at.strftime('%d.%m.%Y %H:%M'),
}
})
@app.post("/api/digest/{digest_id}/update_text")
async def api_update_digest_text(digest_id: int, request: Request, username: str = Depends(get_current_admin)):
"""Обновить текст дайджеста"""
from services.digest_service import DigestService
data = await request.json()
new_text = data.get('text', '')
async with AsyncSessionLocal() as session:
service = DigestService(session)
success = await service.update_digest_text(digest_id, new_text)
if success:
return JSONResponse({"success": True, "message": "Текст обновлён"})
else:
return JSONResponse({"error": "Ошибка обновления текста"}, status_code=500)
@app.get("/digest_settings", response_class=HTMLResponse)
async def digest_settings_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница настроек дайджеста"""
return templates.TemplateResponse("digest_settings.html", {
"request": request,
"username": username,
})
if __name__ == "__main__":
run_web_server()

View file

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📢 Умные рассылки - Домовой Бот</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.header h1 { font-size: 2em; margin-bottom: 10px; }
.back-link { color: white; text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.stat-card h3 { font-size: 0.9em; color: #666; margin-bottom: 5px; }
.stat-card .value { font-size: 2em; font-weight: bold; color: #667eea; }
table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #667eea; color: white; padding: 15px; text-align: left; }
td { padding: 15px; border-bottom: 1px solid #eee; }
tr:hover { background: #f8f9fa; }
.btn { padding: 8px 16px; border: none; border-radius: 5px; cursor: pointer; font-size: 0.9em; transition: all 0.2s; }
.btn-primary { background: #667eea; color: white; }
.btn-primary:hover { background: #5568d3; }
.btn-success { background: #48bb78; color: white; }
.btn-success:hover { background: #38a169; }
.btn-warning { background: #ed8936; color: white; }
.btn-warning:hover { background: #dd6b20; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.badge-success { background: #c6f6d5; color: #22543d; }
.badge-warning { background: #feebc8; color: #7c2d12; }
.badge-info { background: #bee3f8; color: #2c5282; }
.progress-bar { width: 100%; height: 20px; background: #e2e8f0; border-radius: 10px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); transition: width 0.3s; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content { background: white; padding: 30px; border-radius: 10px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/" class="back-link">На главную</a>
<h1>📢 Умные рассылки</h1>
<p>Отслеживание прочтений и статистика</p>
</div>
<div class="stats">
<div class="stat-card">
<h3>Всего рассылок</h3>
<div class="value">{{ broadcasts|length }}</div>
</div>
<div class="stat-card">
<h3>Всего отправлено</h3>
<div class="value">{{ broadcasts|sum(attribute='total_sent') }}</div>
</div>
<div class="stat-card">
<h3>Всего прочитано</h3>
<div class="value">{{ broadcasts|sum(attribute='read_count') }}</div>
</div>
<div class="stat-card">
<h3>Средний % прочтений</h3>
<div class="value">{% if broadcasts|length > 0 %}{{ (broadcasts|sum(attribute='read_percent') / broadcasts|length)|round(1) }}%{% else %}0%{% endif %}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Текст</th>
<th>Дата</th>
<th>Отправлено</th>
<th>Прочитано</th>
<th>Не прочитано</th>
<th>% прочтений</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for b in broadcasts %}
<tr>
<td>#{{ b.id }}</td>
<td>{{ b.text }}</td>
<td>{{ b.sent_at }}</td>
<td>{{ b.total_sent }}</td>
<td><span class="badge badge-success">{{ b.read_count }}</span></td>
<td><span class="badge badge-warning">{{ b.unread_count }}</span></td>
<td>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ b.read_percent }}%"></div>
</div>
<small>{{ b.read_percent }}%</small>
</td>
<td>
<button class="btn btn-primary" onclick="showReaders({{ b.id }})">👥 Кто прочитал</button>
<button class="btn btn-warning" onclick="showNonReaders({{ b.id }})">⚠️ Кто НЕ прочитал</button>
{% if not b.is_reminder_sent %}
<button class="btn btn-success" onclick="sendReminder({{ b.id }})">📬 Напоминание</button>
{% else %}
<span class="badge badge-info">Напоминание отправлено</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if broadcasts|length == 0 %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px; margin-top: 20px;">
<h2>📭 Рассылок пока нет</h2>
<p style="margin-top: 10px;">Используйте команду /broadcast в Telegram для создания рассылки</p>
</div>
{% endif %}
</div>
<!-- Модальное окно для списка прочитавших -->
<div id="readersModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>👥 Прочитавшие</h2>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div id="readersList"></div>
</div>
</div>
<!-- Модальное окно для списка непрочитавших -->
<div id="nonReadersModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>⚠️ Не прочитавшие</h2>
<button class="close-btn" onclick="closeNonReadersModal()">&times;</button>
</div>
<div id="nonReadersList"></div>
</div>
</div>
<script>
function showReaders(broadcastId) {
fetch(`/api/broadcast/readers/${broadcastId}`)
.then(r => r.json())
.then(data => {
const list = document.getElementById('readersList');
if (data.readers.length === 0) {
list.innerHTML = '<p>Пока никто не прочитал</p>';
} else {
list.innerHTML = '<table><tr><th>Имя</th><th>Квартира</th><th>Время прочтения</th></tr>' +
data.readers.map(r => `<tr><td>${r.name}</td><td>${r.apartment || '-'}</td><td>${r.read_at}</td></tr>`).join('') +
'</table>';
}
document.getElementById('readersModal').classList.add('active');
});
}
function closeModal() {
document.getElementById('readersModal').classList.remove('active');
}
function showNonReaders(broadcastId) {
fetch(`/api/broadcast/non_readers/${broadcastId}`)
.then(r => r.json())
.then(data => {
const list = document.getElementById('nonReadersList');
if (data.non_readers.length === 0) {
list.innerHTML = '<p>Все прочитали!</p>';
} else {
list.innerHTML = '<table><tr><th>Имя</th><th>Квартира</th></tr>' +
data.non_readers.map(r => `<tr><td>${r.name}</td><td>${r.apartment || '-'}</td></tr>`).join('') +
'</table>';
}
document.getElementById('nonReadersModal').classList.add('active');
});
}
function closeNonReadersModal() {
document.getElementById('nonReadersModal').classList.remove('active');
}
function sendReminder(broadcastId) {
if (confirm('Отправить напоминание непрочитавшим?')) {
fetch(`/api/broadcast/send_reminder/${broadcastId}`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
</script>
</body>
</html>

View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚙️ Настройки дайджеста - Домовой Бот</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.header h1 { font-size: 2em; margin-bottom: 10px; }
.back-link { color: white; text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; }
.card { background: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.card h3 { margin-bottom: 15px; color: #667eea; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; }
.form-group input[type="time"], .form-group select { width: 100%; padding: 10px; border: 2px solid #e2e8f0; border-radius: 5px; font-size: 1em; }
.checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.checkbox-group input[type="checkbox"] { width: 20px; height: 20px; }
.btn { padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; background: #667eea; color: white; }
.btn:hover { background: #5568d3; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/digests" class="back-link">← Назад к дайджестам</a>
<h1>⚙️ Настройки дайджеста</h1>
</div>
<div class="card">
<h3>📅 Расписание</h3>
<div class="form-group">
<label>Автоматическая генерация</label>
<div class="checkbox-group">
<input type="checkbox" id="autoGenerate" checked>
<label for="autoGenerate">Включено (каждое воскресенье в 09:00)</label>
</div>
</div>
<div class="form-group">
<label>Время генерации</label>
<input type="time" value="09:00" disabled>
</div>
</div>
<div class="card">
<h3>📊 Содержимое дайджеста</h3>
<div class="checkbox-group">
<input type="checkbox" id="includeEvents" checked>
<label for="includeEvents">🔔 События</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includePolls" checked>
<label for="includePolls">📊 Опросы</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includeAds" checked>
<label for="includeAds">📢 Объявления</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includeTopUsers" checked>
<label for="includeTopUsers">🏆 Топ активных жильцов</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includeSchedule" checked>
<label for="includeSchedule">⏰ Запланированные отключения</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includeVerifications" checked>
<label for="includeVerifications">✅ Верификации</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="includeStats" checked>
<label for="includeStats">📈 Статистика чата</label>
</div>
</div>
<div class="card">
<h3>📬 Отправка</h3>
<div class="checkbox-group">
<input type="checkbox" id="sendPreview" checked>
<label for="sendPreview">Отправлять превью админу для утверждения</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="autoSend">
<label for="autoSend">Публиковать автоматически (без утверждения)</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="saveArchive" checked>
<label for="saveArchive">Сохранять в архив</label>
</div>
</div>
<button class="btn" onclick="saveSettings()">💾 Сохранить настройки</button>
</div>
<script>
function saveSettings() {
alert('✅ Настройки сохранены! (Функционал в разработке)');
}
</script>
</body>
</html>

250
web/templates/digests.html Normal file
View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📰 Дайджесты - Домовой Бот</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 30px; }
.header h1 { font-size: 2em; margin-bottom: 10px; }
.back-link { color: white; text-decoration: none; opacity: 0.9; }
.back-link:hover { opacity: 1; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
.tab { padding: 12px 24px; background: white; border: none; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; }
.tab.active { background: #f5576c; color: white; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.pending-card { background: white; padding: 25px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-left: 5px solid #f5576c; }
.pending-card h3 { color: #f5576c; margin-bottom: 10px; }
.pending-card .meta { color: #666; font-size: 0.9em; margin-bottom: 15px; }
.pending-card .text { background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; }
.btn { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: all 0.2s; }
.btn-approve { background: #48bb78; color: white; }
.btn-approve:hover { background: #38a169; }
.btn-reject { background: #f56565; color: white; }
.btn-reject:hover { background: #e53e3e; }
.btn-edit { background: #ed8936; color: white; }
.btn-edit:hover { background: #dd6b20; }
.btn-send { background: #4299e1; color: white; }
.btn-send:hover { background: #3182ce; }
table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #f5576c; color: white; padding: 15px; text-align: left; }
td { padding: 15px; border-bottom: 1px solid #eee; }
tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.badge-pending { background: #feebc8; color: #7c2d12; }
.badge-approved { background: #c6f6d5; color: #22543d; }
.badge-rejected { background: #fed7d7; color: #742a2a; }
.badge-sent { background: #bee3f8; color: #2c5282; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
.modal.active { display: flex; align-items: center; justify-content: center; }
.modal-content { background: white; padding: 30px; border-radius: 10px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; }
textarea { width: 100%; min-height: 300px; padding: 15px; border: 2px solid #e2e8f0; border-radius: 5px; font-family: inherit; font-size: 0.95em; }
textarea:focus { outline: none; border-color: #f5576c; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/" class="back-link">На главную</a>
<h1>📰 Еженедельные дайджесты</h1>
<p>Утверждение, редактирование и архив</p>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('pending')">⏳ Ожидают утверждения</button>
<button class="tab" onclick="showTab('archive')">📚 Архив</button>
</div>
<div id="pending" class="tab-content active">
{% if pending_digests|length > 0 %}
{% for digest in pending_digests %}
<div class="pending-card">
<h3>📰 Дайджест #{{ digest.week }}/{{ digest.year }}</h3>
<div class="meta">
Период: {{ digest.period_label }} | Создан: {{ digest.created_at }} | Статус: <span class="badge badge-pending">{{ digest.status_emoji }} {{ digest.status }}</span>
</div>
<div class="text">{{ digest.text }}</div>
<div style="margin-top: 15px;">
<button class="btn btn-approve" onclick="approveDigest({{ digest.id }})">✅ Утвердить</button>
<button class="btn btn-edit" onclick="editDigest({{ digest.id }})">✏️ Редактировать</button>
<button class="btn btn-reject" onclick="rejectDigest({{ digest.id }})">❌ Отклонить</button>
<button class="btn btn-send" onclick="sendDigest({{ digest.id }})" id="sendBtn{{ digest.id }}" style="display:none;">📨 Отправить</button>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
<h2>📭 Нет ожидающих утверждения дайджестов</h2>
<p style="margin-top: 10px;">Следующий дайджест будет сгенерирован в воскресенье в 09:00</p>
<button class="btn btn-send" style="margin-top: 20px;" onclick="generateNow()">🔄 Сгенерировать сейчас</button>
</div>
{% endif %}
</div>
<div id="archive" class="tab-content">
{% if archive_digests|length > 0 %}
<table>
<thead>
<tr>
<th>Неделя</th>
<th>Период</th>
<th>Статус</th>
<th>Отправлен</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for digest in archive_digests %}
<tr>
<td>#{{ digest.week }}/{{ digest.year }}</td>
<td>{{ digest.period_label }}</td>
<td><span class="badge badge-{{ digest.status }}">{{ digest.status_emoji }} {{ digest.status }}</span></td>
<td>{{ digest.sent_at }}</td>
<td>
<button class="btn btn-edit" onclick="viewDigest({{ digest.id }})">👁️ Просмотр</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
<h2>📚 Архив пуст</h2>
<p style="margin-top: 10px;">Здесь будут отображаться все дайджесты</p>
</div>
{% endif %}
</div>
</div>
<!-- Модальное окно для редактирования -->
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>✏️ Редактирование дайджеста</h2>
<button class="close-btn" onclick="closeEditModal()">&times;</button>
</div>
<textarea id="editText"></textarea>
<div style="margin-top: 15px;">
<button class="btn btn-approve" onclick="saveEdit()">💾 Сохранить</button>
<button class="btn btn-reject" onclick="closeEditModal()">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для просмотра -->
<div id="viewModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="viewTitle">📰 Дайджест</h2>
<button class="close-btn" onclick="closeViewModal()">&times;</button>
</div>
<div id="viewText" style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;"></div>
</div>
</div>
<script>
let currentDigestId = null;
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabName).classList.add('active');
}
function approveDigest(id) {
if (confirm('Утвердить этот дайджест?')) {
fetch(`/api/digest/${id}/approve`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
// Показываем кнопку "Отправить"
document.getElementById(`sendBtn${id}`).style.display = 'inline-block';
location.reload();
});
}
}
function rejectDigest(id) {
const reason = prompt('Причина отклонения:');
if (reason) {
fetch(`/api/digest/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason })
})
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
function editDigest(id) {
currentDigestId = id;
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('editText').value = data.digest.text;
document.getElementById('editModal').classList.add('active');
});
}
function saveEdit() {
const newText = document.getElementById('editText').value;
fetch(`/api/digest/${currentDigestId}/update_text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText })
})
.then(r => r.json())
.then(data => {
alert(data.message);
closeEditModal();
location.reload();
});
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
}
function viewDigest(id) {
fetch(`/api/digest/${id}`)
.then(r => r.json())
.then(data => {
document.getElementById('viewTitle').textContent = `📰 Дайджест #${data.digest.week}/${data.digest.year}`;
document.getElementById('viewText').textContent = data.digest.text;
document.getElementById('viewModal').classList.add('active');
});
}
function closeViewModal() {
document.getElementById('viewModal').classList.remove('active');
}
function sendDigest(id) {
if (confirm('Отправить дайджест всем верифицированным жильцам?')) {
fetch(`/api/digest/${id}/send`, { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
location.reload();
});
}
}
function generateNow() {
alert('Генерация дайджеста запущена! Проверьте Telegram - бот отправит превью.');
}
</script>
</body>
</html>

View file

@ -0,0 +1,326 @@
# Руководство V3.1 - Умные рассылки и Еженедельные дайджесты
> Дата: 6 апреля 2026 г.
> Версия: 3.1
> Статус: ✅ Готово к использованию
---
## 📢 Умные рассылки с отслеживанием прочтения
### Что это?
Обычная рассылка, но с возможностью **отслеживать кто прочитал** сообщение. Каждый получатель видит кнопку "✅ Прочитал", а админ видит статистику в реальном времени.
### Как использовать
#### Из Telegram (для админа)
**Быстрая рассылка:**
```
/broadcast_quick <текст сообщения>
```
**Пошаговое создание:**
```
/broadcast
```
1. Бот попросит ввести текст
2. Выберете получателей (чат, все верифицированные, ИГ)
3. Подтвердите отправку
**Просмотр статистики:**
```
/broadcast_stats
```
#### Из веб-панели
Откройте: `http://localhost:8000/broadcasts`
**Возможности:**
- 📊 Статистика всех рассылок
- 👥 Посмотреть кто прочитал
- ⚠️ Посмотреть кто НЕ прочитал
- 📬 Отправить напоминание непрочитавшим
- 📈 Процент прочтений с прогресс-баром
### Как работает
1. Вы создаёте рассылку
2. Бот отправляет сообщение с кнопкой "✅ Прочитал"
3. Жилец нажимает кнопку
4. В БД сохраняется: user_id + broadcast_id + timestamp
5. В веб-панели вы видите:
- Сколько прочитали (в абсолютных числах и %)
- Кто конкретно прочитал
- Кто НЕ прочитал
- Можно отправить напоминание только непрочитавшим
### Пример
```
📢 Объявление от администрации
Завтра отключение горячей воды с 10:00 до 18:00!
[✅ Прочитал]
```
**Статистика в веб-панели:**
```
Рассылка #42
Отправлено: 245 жильцам
Прочитали: 187 (76.3%)
Не прочитали: 58
[👥 Кто прочитал] [⚠️ Кто НЕ прочитал] [📬 Напоминание]
```
---
## 📰 Еженедельный дайджест
### Что это?
Автоматически сгенерированный отчёт за неделю со всей важной информацией:
- События
- Опросы
- Объявления
- Топ активных жильцов
- Запланированные отключения
- Статистика чата
### Автоматический процесс
**Воскресенье 09:00:**
1. Бот собирает данные за неделю
2. Генерирует дайджест
3. Сохраняет в БД со статусом `pending_approval`
4. Отправляет админу превью в Telegram
### Утверждение
#### Из Telegram
Админ получает сообщение:
```
📰 Дайджест за неделю готов!
Неделя #14/2026
Период: 30.03 - 05.04.2026
[🌐 Утвердить в веб-панели]
```
#### Из веб-панели
Откройте: `http://localhost:8000/digests`
**Возможности:**
- 👁️ Просмотреть текст дайджеста
- ✅ Утвердить
- ✏️ Редактировать текст
- ❌ Отклонить с указанием причины
- 📨 Отправить утверждённый дайджест
- 📚 Архив всех дайджестов
### Содержимое дайджеста
**Автоматически собирается:**
1. **🔔 События** - все события за неделю
2. **📊 Опросы** - активные и завершённые
3. **📢 Объявления** - количество новых и активных
4. **🏆 Топ-5 жильцов** - по активности в чате
5. **⏰ Отключения** - на следующую неделю
6. **✅ Верификации** - сколько новых верифицировано
7. **📈 Статистика** - сообщения, активные пользователи
### Пример дайджеста
```
📰 ДАЙДЖЕСТ ДОМА ЗА НЕДЕЛЮ
🗓 30.03 - 05.04.2026
🔔 События:
• Субботник 5 апреля — осталось 15 мест
• Собрание жильцов 8 апреля в 19:00
📊 Опросы:
• "Цвет фасада" — 89 голосов (✅ активен)
• "Время уборки" — 156 голосов (🔴 завершён)
📢 Объявления: 12 новых
Всего активных: 45
🏆 Активные жильцы:
🥇 Иванов Иван (кв. 42) — 87 сообщ.
🥈 Петрова Мария (кв. 15) — 65 сообщ.
🥉 Сидоров Алексей (кв. 78) — 54 сообщ.
На следующей неделе:
💧 Отключение ГВС — 07.04 10:00 до 18:00
✅ Верификации:
• Новых за неделю: 8
Всего верифицировано: 237
📈 Статистика:
• Сообщений в чате: 456
• Активных жильцов: 89
```
### Архив дайджестов
Все дайджесты сохраняются в БД. Можно посмотреть:
- Дайджест за любую неделю
- Статус (отправлен/отклонён)
- Кто утвердил
- Когда отправлен
**Команды для будущего:**
```
/digest_archive месяц # Дайджесты за месяц
/digest_archive год # Дайджесты за год
/digest_export # Экспорт всех дайджестов
```
---
## 🔧 Технические детали
### Новые таблицы БД
**broadcasts** - умные рассылки
- id, message_id, text, photo_file_id
- sent_at, sent_by, total_sent
- has_read_button, is_reminder_sent
- broadcast_type (manual, digest, emergency)
**broadcast_reads** - кто прочитал
- id, broadcast_id, user_id, read_at
**digests** - еженедельные дайджесты
- id, period_start, period_end
- week_number, year, content (JSON)
- digest_text, status
- approved_at, approved_by, sent_at
- rejection_reason
### Команды Telegram
| Команда | Описание |
|---------|----------|
| `/broadcast` | Создать рассылку (пошагово) |
| `/broadcast_quick <текст>` | Быстрая рассылка |
| `/broadcast_stats` | Статистика рассылок |
| `/digest` | Сгенерировать дайджест вручную |
### Веб-эндпоинты
| URL | Описание |
|-----|----------|
| `/broadcasts` | Страница рассылок |
| `/api/broadcast/readers/{id}` | Кто прочитал |
| `/api/broadcast/non_readers/{id}` | Кто НЕ прочитал |
| `/api/broadcast/send_reminder/{id}` | Отправить напоминание |
| `/digests` | Страница дайджестов |
| `/api/digest/{id}/approve` | Утвердить дайджест |
| `/api/digest/{id}/reject` | Отклонить дайджест |
| `/api/digest/{id}/send` | Отправить дайджест |
| `/api/digest/{id}/update_text` | Редактировать текст |
| `/digest_settings` | Настройки дайджеста |
### Расписание
**Автоматические задачи:**
| Задача | Когда | Что делает |
|--------|-------|------------|
| Генерация дайджеста | Воскресенье 09:00 | Собирает данные, сохраняет, уведомляет админа |
---
## 📋 План внедрения
### ✅ Реализовано
- [x] Модели БД
- [x] Миграция
- [x] Обработчики умной рассылки
- [x] Обработчики дайджеста
- [x] Сервис генерации дайджеста
- [x] APScheduler задача
- [x] Веб-панель рассылок
- [x] Веб-панель дайджестов
- [x] Документация
### 🔜 В следующем релизе
- [ ] Кнопка "Прочитал" с правильной идентификацией рассылки
- [ ] Настройки дайджеста (что включать/исключать)
- [ ] Экспорт дайджестов в PDF
- [ ] Генерация квартального/годового дайджеста
- [ ] A/B тестирование текстов рассылок
- [ ] Лучшее время для отправки (аналитика)
---
## 🎯 Быстрый старт
### 1. Проверить миграцию
```bash
cd /home/matrixhasyou/domovoy_bot
source venv/bin/activate
python database/migrate_v3_1_smart_broadcasts.py
```
### 2. Перезапустить бота
```bash
sudo systemctl restart domovoy-bot
```
### 3. Протестировать рассылку
```
/broadcast_quick Тестовая рассылка V3.1
```
### 4. Открыть веб-панель
```
http://localhost:8000/broadcasts
http://localhost:8000/digests
```
### 5. Сгенерировать тестовый дайджест
```
/digest
```
---
## 🐛 Известные особенности
1. **Кнопка "Прочитал"** - пока не идентифицирует конкретную рассылку (будет улучшено)
2. **Настройки дайджеста** - страница есть, но функционал в разработке
3. **Экспорт дайджестов** - планируется в V3.2
---
## 📞 Поддержка
При возникновении проблем:
1. Проверьте логи бота: `journalctl -u domovoy-bot -f`
2. Проверьте БД: таблицы `broadcasts`, `broadcast_reads`, `digests`
3. Откройте issue в Gitea
---
**Версия:** V3.1
**Дата выхода:** 6 апреля 2026 г.
**Автор:** Домовой Бот Team 🏠