- Добавлены функции to_local() и fmt_local() - Время рассылок теперь 17:55 вместо 13:55 - Время прочтений в локальном поясе - Время запланированных постов исправлено - Время дайджестов исправлено Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
414 lines
15 KiB
Python
414 lines
15 KiB
Python
"""
|
||
Сервис генерации еженедельного дайджеста
|
||
"""
|
||
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
|
||
]
|