diff --git a/database/migrate_v3_1_smart_broadcasts.py b/database/migrate_v3_1_smart_broadcasts.py new file mode 100644 index 0000000..0ee84f0 --- /dev/null +++ b/database/migrate_v3_1_smart_broadcasts.py @@ -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() diff --git a/database/models.py b/database/models.py index c72717f..9285499 100644 --- a/database/models.py +++ b/database/models.py @@ -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"" + + @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"" + + +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"" + + @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')}" diff --git a/handlers/digest.py b/handlers/digest.py new file mode 100644 index 0000000..b26d4f4 --- /dev/null +++ b/handlers/digest.py @@ -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"📰 Дайджест #{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"{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"📰 Еженедельный дайджест\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"📰 Еженедельный дайджест\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"✅ Дайджест отправлен!\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"📰 Дайджест за неделю готов!\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}" + ) diff --git a/handlers/smart_broadcast.py b/handlers/smart_broadcast.py new file mode 100644 index 0000000..a055f0a --- /dev/null +++ b/handlers/smart_broadcast.py @@ -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( + "📢 Умная рассылка с отслеживанием прочтения\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( + "📢 Создание умной рассылки\n\n" + "Введите текст рассылки:\n\n" + "💡 Совет: используйте HTML разметку\n" + "жирный, курсив, код", + 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"📢 Рассылка готова!\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"📢 Объявление от администрации\n\n{text}", + reply_markup=keyboard, + parse_mode='HTML' + ) + else: + msg = await bot.send_message( + ADMIN_CHAT_ID, + f"📢 Объявление от администрации\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"📢 Объявление от администрации\n\n{text}", + reply_markup=keyboard, + parse_mode='HTML' + ) + else: + await bot.send_message( + user.user_id, + f"📢 Объявление от администрации\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"📊 Результаты рассылки #{broadcast_id}\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"📊 Статистика рассылок\n\n" + text += f"Всего рассылок: {total}\n\n" + + if recent: + text += "Последние рассылки:\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"⚠️ Напоминание\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"📬 Напоминание отправлено\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 diff --git a/main.py b/main.py index 0a2544b..85fd172 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/services/digest.py b/services/digest.py new file mode 100644 index 0000000..0402970 --- /dev/null +++ b/services/digest.py @@ -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"📰 ДАЙДЖЕСТ ДОМА ЗА НЕДЕЛЮ\n" + text += f"🗓 {period_start} - {period_end}\n\n" + + # События + text += f"🔔 События:\n" + if events: + for e in events: + text += f"• {e['title']} — {e['event_date']}\n" + else: + text += "• Нет событий\n" + text += "\n" + + # Опросы + text += f"📊 Опросы:\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"📢 Объявления: {ads['new_count']} новых\n" + text += f" Всего активных: {ads['active_count']}\n\n" + + # Топ жильцов + if top_users: + text += f"🏆 Активные жильцы:\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"⏰ На следующей неделе:\n" + for s in schedule: + text += f"{s['emoji']} {s['title']} — {s['start_time']} до {s['end_time']}\n" + text += "\n" + + # Верификации + text += f"✅ Верификации:\n" + text += f"• Новых за неделю: {verifications['new_verified']}\n" + text += f"• Всего верифицировано: {verifications['total_verified']}\n\n" + + # Статистика + text += f"📈 Статистика:\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 + ] diff --git a/services/scheduler.py b/services/scheduler.py index be60e3e..7e0f5d8 100644 --- a/services/scheduler.py +++ b/services/scheduler.py @@ -110,6 +110,16 @@ class Scheduler: replace_existing=True ) 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): """Остановка планировщика""" @@ -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}') diff --git a/web/app.py b/web/app.py index 770ff09..f1b47a3 100644 --- a/web/app.py +++ b/web/app.py @@ -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() diff --git a/web/templates/broadcasts.html b/web/templates/broadcasts.html new file mode 100644 index 0000000..ef53c1b --- /dev/null +++ b/web/templates/broadcasts.html @@ -0,0 +1,195 @@ + + + + + + 📢 Умные рассылки - Домовой Бот + + + +
+
+ ← На главную +

📢 Умные рассылки

+

Отслеживание прочтений и статистика

+
+ +
+
+

Всего рассылок

+
{{ broadcasts|length }}
+
+
+

Всего отправлено

+
{{ broadcasts|sum(attribute='total_sent') }}
+
+
+

Всего прочитано

+
{{ broadcasts|sum(attribute='read_count') }}
+
+
+

Средний % прочтений

+
{% if broadcasts|length > 0 %}{{ (broadcasts|sum(attribute='read_percent') / broadcasts|length)|round(1) }}%{% else %}0%{% endif %}
+
+
+ + + + + + + + + + + + + + + + {% for b in broadcasts %} + + + + + + + + + + + {% endfor %} + +
IDТекстДатаОтправленоПрочитаноНе прочитано% прочтенийДействия
#{{ b.id }}{{ b.text }}{{ b.sent_at }}{{ b.total_sent }}{{ b.read_count }}{{ b.unread_count }} +
+
+
+ {{ b.read_percent }}% +
+ + + {% if not b.is_reminder_sent %} + + {% else %} + Напоминание отправлено + {% endif %} +
+ + {% if broadcasts|length == 0 %} +
+

📭 Рассылок пока нет

+

Используйте команду /broadcast в Telegram для создания рассылки

+
+ {% endif %} +
+ + + + + + + + + + diff --git a/web/templates/digest_settings.html b/web/templates/digest_settings.html new file mode 100644 index 0000000..6143a20 --- /dev/null +++ b/web/templates/digest_settings.html @@ -0,0 +1,105 @@ + + + + + + ⚙️ Настройки дайджеста - Домовой Бот + + + +
+
+ ← Назад к дайджестам +

⚙️ Настройки дайджеста

+
+ +
+

📅 Расписание

+
+ +
+ + +
+
+
+ + +
+
+ +
+

📊 Содержимое дайджеста

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

📬 Отправка

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + + diff --git a/web/templates/digests.html b/web/templates/digests.html new file mode 100644 index 0000000..90f9cea --- /dev/null +++ b/web/templates/digests.html @@ -0,0 +1,250 @@ + + + + + + 📰 Дайджесты - Домовой Бот + + + +
+
+ ← На главную +

📰 Еженедельные дайджесты

+

Утверждение, редактирование и архив

+
+ +
+ + +
+ +
+ {% if pending_digests|length > 0 %} + {% for digest in pending_digests %} +
+

📰 Дайджест #{{ digest.week }}/{{ digest.year }}

+
+ Период: {{ digest.period_label }} | Создан: {{ digest.created_at }} | Статус: {{ digest.status_emoji }} {{ digest.status }} +
+
{{ digest.text }}
+
+ + + + +
+
+ {% endfor %} + {% else %} +
+

📭 Нет ожидающих утверждения дайджестов

+

Следующий дайджест будет сгенерирован в воскресенье в 09:00

+ +
+ {% endif %} +
+ +
+ {% if archive_digests|length > 0 %} + + + + + + + + + + + + {% for digest in archive_digests %} + + + + + + + + {% endfor %} + +
НеделяПериодСтатусОтправленДействия
#{{ digest.week }}/{{ digest.year }}{{ digest.period_label }}{{ digest.status_emoji }} {{ digest.status }}{{ digest.sent_at }} + +
+ {% else %} +
+

📚 Архив пуст

+

Здесь будут отображаться все дайджесты

+
+ {% endif %} +
+
+ + + + + + + + + + diff --git a/РУКОВОДСТВО_V3.1_УМНЫЕ_РАССЫЛКИ.md b/РУКОВОДСТВО_V3.1_УМНЫЕ_РАССЫЛКИ.md new file mode 100644 index 0000000..88e859a --- /dev/null +++ b/РУКОВОДСТВО_V3.1_УМНЫЕ_РАССЫЛКИ.md @@ -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 🏠