🚀 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:
parent
f958446767
commit
222c2ba521
12 changed files with 2502 additions and 1 deletions
51
database/migrate_v3_1_smart_broadcasts.py
Normal file
51
database/migrate_v3_1_smart_broadcasts.py
Normal 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()
|
||||||
|
|
@ -655,3 +655,109 @@ class ScheduledPost(Base):
|
||||||
'cancelled': '🚫',
|
'cancelled': '🚫',
|
||||||
}
|
}
|
||||||
return emojis.get(self.status, '❓')
|
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
319
handlers/digest.py
Normal 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
410
handlers/smart_broadcast.py
Normal 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
|
||||||
9
main.py
9
main.py
|
|
@ -288,6 +288,15 @@ async def main():
|
||||||
dp.include_router(events_router)
|
dp.include_router(events_router)
|
||||||
dp.include_router(initiative_group_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.startup.register(on_startup)
|
||||||
dp.shutdown.register(on_shutdown)
|
dp.shutdown.register(on_shutdown)
|
||||||
|
|
|
||||||
414
services/digest.py
Normal file
414
services/digest.py
Normal 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
|
||||||
|
]
|
||||||
|
|
@ -111,6 +111,16 @@ class Scheduler:
|
||||||
)
|
)
|
||||||
logger.info('✅ Добавлена задача проверки запланированных постов')
|
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):
|
def stop(self):
|
||||||
"""Остановка планировщика"""
|
"""Остановка планировщика"""
|
||||||
self.scheduler.shutdown()
|
self.scheduler.shutdown()
|
||||||
|
|
@ -463,3 +473,12 @@ class Scheduler:
|
||||||
finally:
|
finally:
|
||||||
# Снимаем блокировку
|
# Снимаем блокировку
|
||||||
self._scheduled_posts_lock = False
|
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}')
|
||||||
|
|
|
||||||
299
web/app.py
299
web/app.py
|
|
@ -21,7 +21,8 @@ import config
|
||||||
from database.db import AsyncSessionLocal
|
from database.db import AsyncSessionLocal
|
||||||
from database.models import (
|
from database.models import (
|
||||||
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
||||||
VerificationRequest, Service, Announcement, Event, ScheduledPost
|
VerificationRequest, Service, Announcement, Event, ScheduledPost,
|
||||||
|
Broadcast, BroadcastRead, Digest
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
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__":
|
if __name__ == "__main__":
|
||||||
run_web_server()
|
run_web_server()
|
||||||
|
|
|
||||||
195
web/templates/broadcasts.html
Normal file
195
web/templates/broadcasts.html
Normal 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()">×</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()">×</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>
|
||||||
105
web/templates/digest_settings.html
Normal file
105
web/templates/digest_settings.html
Normal 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
250
web/templates/digests.html
Normal 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()">×</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()">×</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>
|
||||||
326
РУКОВОДСТВО_V3.1_УМНЫЕ_РАССЫЛКИ.md
Normal file
326
РУКОВОДСТВО_V3.1_УМНЫЕ_РАССЫЛКИ.md
Normal 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 🏠
|
||||||
Loading…
Reference in a new issue