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