372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""
|
||
Celery задачи для уведомлений.
|
||
"""
|
||
from celery import shared_task
|
||
from django.core.mail import send_mail, EmailMultiAlternatives
|
||
from django.conf import settings
|
||
from django.template.loader import render_to_string
|
||
from django.utils.html import strip_tags
|
||
from django.utils import timezone
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@shared_task
|
||
def send_notification_task(notification_id):
|
||
"""
|
||
Отправка уведомления асинхронно.
|
||
|
||
Args:
|
||
notification_id: ID уведомления для отправки
|
||
"""
|
||
from .models import Notification
|
||
|
||
try:
|
||
notification = Notification.objects.select_related('recipient').get(id=notification_id)
|
||
|
||
# Проверка настроек пользователя
|
||
try:
|
||
preferences = notification.recipient.notification_preferences
|
||
|
||
# Проверка включены ли уведомления
|
||
if not preferences.is_type_enabled(notification.notification_type, notification.channel):
|
||
notification.mark_as_sent(error='Отключено в настройках')
|
||
return f'Notification {notification_id} disabled by user preferences'
|
||
|
||
# Проверка режима тишины
|
||
if preferences.is_quiet_hours():
|
||
# Перенести отправку
|
||
return f'Notification {notification_id} delayed due to quiet hours'
|
||
|
||
except:
|
||
# Если нет настроек, используем дефолтные
|
||
pass
|
||
|
||
# Отправка в зависимости от канала
|
||
if notification.channel == 'email':
|
||
result = send_email_notification(notification)
|
||
|
||
elif notification.channel == 'telegram':
|
||
result = send_telegram_notification(notification)
|
||
|
||
elif notification.channel == 'in_app':
|
||
# Внутренние уведомления только создаются, не отправляются
|
||
notification.mark_as_sent()
|
||
result = 'In-app notification created'
|
||
|
||
else:
|
||
result = f'Unknown channel: {notification.channel}'
|
||
|
||
return result
|
||
|
||
except Notification.DoesNotExist:
|
||
return f'Notification {notification_id} not found'
|
||
|
||
except Exception as e:
|
||
logger.error(f'Error sending notification {notification_id}: {str(e)}')
|
||
try:
|
||
notification = Notification.objects.get(id=notification_id)
|
||
notification.mark_as_sent(error=str(e))
|
||
except:
|
||
pass
|
||
return f'Error: {str(e)}'
|
||
|
||
|
||
def send_email_notification(notification):
|
||
"""Отправка Email уведомления."""
|
||
try:
|
||
recipient_email = notification.recipient.email
|
||
|
||
# Пытаемся получить шаблон для этого типа уведомления
|
||
from .models import NotificationTemplate
|
||
|
||
try:
|
||
template = NotificationTemplate.objects.get(
|
||
notification_type=notification.notification_type,
|
||
is_active=True
|
||
)
|
||
|
||
# Подготавливаем контекст для шаблона
|
||
context = {
|
||
'title': notification.title,
|
||
'message': notification.message,
|
||
'action_url': f"{settings.FRONTEND_URL}{notification.action_url}" if notification.action_url else None,
|
||
'recipient_name': notification.recipient.get_full_name() or notification.recipient.email,
|
||
'recipient_email': notification.recipient.email,
|
||
}
|
||
|
||
# Добавляем данные из notification.data, если есть
|
||
if notification.data:
|
||
context.update(notification.data)
|
||
|
||
# Рендерим шаблон
|
||
rendered = template.render('email', context)
|
||
email_subject = rendered.get('subject', notification.title)
|
||
email_body = rendered.get('body', '')
|
||
|
||
# Если шаблон не содержит body, используем дефолтный
|
||
if not email_body:
|
||
email_body = f"""
|
||
<html>
|
||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px;">
|
||
<h2 style="color: #333;">{notification.title}</h2>
|
||
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||
<p style="color: #666; line-height: 1.6;">{notification.message}</p>
|
||
</div>
|
||
{f'<a href="{settings.FRONTEND_URL}{notification.action_url}" style="display: inline-block; background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Перейти</a>' if notification.action_url else ''}
|
||
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
||
Это автоматическое уведомление от образовательной платформы.
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
else:
|
||
# Если есть шаблон, оборачиваем его в базовую HTML структуру, если нужно
|
||
if not email_body.strip().startswith('<html'):
|
||
email_body = f"""
|
||
<html>
|
||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px;">
|
||
{email_body}
|
||
{f'<a href="{settings.FRONTEND_URL}{notification.action_url}" style="display: inline-block; background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin-top: 20px;">Перейти</a>' if notification.action_url else ''}
|
||
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
||
Это автоматическое уведомление от образовательной платформы.
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
except NotificationTemplate.DoesNotExist:
|
||
# Если шаблона нет, используем дефолтный
|
||
email_subject = notification.title
|
||
email_body = f"""
|
||
<html>
|
||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px;">
|
||
<h2 style="color: #333;">{notification.title}</h2>
|
||
<div style="background-color: white; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||
<p style="color: #666; line-height: 1.6;">{notification.message}</p>
|
||
</div>
|
||
{f'<a href="{settings.FRONTEND_URL}{notification.action_url}" style="display: inline-block; background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Перейти</a>' if notification.action_url else ''}
|
||
<p style="color: #999; font-size: 12px; margin-top: 20px;">
|
||
Это автоматическое уведомление от образовательной платформы.
|
||
</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
plain_message = strip_tags(email_body)
|
||
|
||
# Отправляем
|
||
msg = EmailMultiAlternatives(
|
||
subject=email_subject,
|
||
body=plain_message,
|
||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||
to=[recipient_email]
|
||
)
|
||
msg.attach_alternative(email_body, "text/html")
|
||
msg.send()
|
||
|
||
notification.mark_as_sent()
|
||
logger.info(f'Email notification sent to {recipient_email}')
|
||
|
||
return f'Email sent to {recipient_email}'
|
||
|
||
except Exception as e:
|
||
logger.error(f'Error sending email notification: {str(e)}')
|
||
notification.mark_as_sent(error=str(e))
|
||
raise
|
||
|
||
|
||
def send_telegram_notification(notification):
|
||
"""Отправка Telegram уведомления."""
|
||
try:
|
||
telegram_id = notification.recipient.telegram_id
|
||
|
||
if not telegram_id:
|
||
notification.mark_as_sent(error='Telegram ID not linked')
|
||
return 'Telegram ID not linked'
|
||
|
||
# Формируем сообщение в HTML формате
|
||
message_text = f"<b>{notification.title}</b>\n\n{notification.message}"
|
||
|
||
if notification.action_url:
|
||
full_url = f"{settings.FRONTEND_URL}{notification.action_url}"
|
||
message_text += f'\n\n<a href="{full_url}">Перейти на платформу</a>'
|
||
|
||
# Отправляем через Telegram Bot API
|
||
from .telegram_bot import send_telegram_message
|
||
import asyncio
|
||
|
||
try:
|
||
# Запускаем асинхронную отправку
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
success = loop.run_until_complete(
|
||
send_telegram_message(telegram_id, message_text, parse_mode='HTML')
|
||
)
|
||
loop.close()
|
||
|
||
if success:
|
||
notification.mark_as_sent()
|
||
logger.info(f'Telegram notification sent to {telegram_id}')
|
||
return f'Telegram notification sent to {telegram_id}'
|
||
else:
|
||
notification.mark_as_sent(error='Failed to send message')
|
||
return 'Failed to send Telegram message'
|
||
|
||
except Exception as e:
|
||
logger.error(f'Error sending telegram message: {str(e)}')
|
||
notification.mark_as_sent(error=str(e))
|
||
return f'Error: {str(e)}'
|
||
|
||
except Exception as e:
|
||
logger.error(f'Error sending telegram notification: {str(e)}')
|
||
notification.mark_as_sent(error=str(e))
|
||
raise
|
||
|
||
|
||
@shared_task
|
||
def send_bulk_notifications(notification_ids):
|
||
"""
|
||
Массовая отправка уведомлений.
|
||
|
||
Args:
|
||
notification_ids: Список ID уведомлений
|
||
"""
|
||
results = []
|
||
for notification_id in notification_ids:
|
||
result = send_notification_task.delay(notification_id)
|
||
results.append(result)
|
||
|
||
return f'Scheduled {len(results)} notifications'
|
||
|
||
|
||
@shared_task
|
||
def cleanup_old_notifications():
|
||
"""
|
||
Очистка старых уведомлений по правилам:
|
||
- прочитанные: удалять если старше 5 дней (по created_at);
|
||
- непрочитанные: удалять если старше 14 дней (по created_at).
|
||
Запускается периодически через Celery Beat (ежедневно в 3:00).
|
||
"""
|
||
from .models import Notification
|
||
from datetime import timedelta
|
||
|
||
now = timezone.now()
|
||
read_threshold = now - timedelta(days=5)
|
||
unread_threshold = now - timedelta(days=14)
|
||
|
||
deleted_read = Notification.objects.filter(
|
||
is_read=True,
|
||
created_at__lt=read_threshold,
|
||
).delete()[0]
|
||
|
||
deleted_unread = Notification.objects.filter(
|
||
is_read=False,
|
||
created_at__lt=unread_threshold,
|
||
).delete()[0]
|
||
|
||
total = deleted_read + deleted_unread
|
||
logger.info(
|
||
'cleanup_old_notifications: deleted read (older than 5 days)=%s, unread (older than 14 days)=%s, total=%s',
|
||
deleted_read, deleted_unread, total,
|
||
)
|
||
return f'Deleted {total} notifications (read >5d: {deleted_read}, unread >14d: {deleted_unread})'
|
||
|
||
|
||
@shared_task
|
||
def send_scheduled_notifications():
|
||
"""
|
||
Отправка отложенных уведомлений.
|
||
Запускается каждую минуту через Celery Beat.
|
||
"""
|
||
from .models import Notification
|
||
|
||
# Находим уведомления которые нужно отправить
|
||
notifications = Notification.objects.filter(
|
||
is_sent=False,
|
||
scheduled_for__lte=timezone.now()
|
||
)
|
||
|
||
count = 0
|
||
for notification in notifications:
|
||
send_notification_task.delay(notification.id)
|
||
count += 1
|
||
|
||
return f'Scheduled {count} notifications'
|
||
|
||
|
||
@shared_task
|
||
def send_lesson_notification(lesson_id, notification_type):
|
||
"""
|
||
Отправка уведомления о занятии по типу события.
|
||
Вызывается из schedule.signals.
|
||
"""
|
||
from apps.schedule.models import Lesson
|
||
from .services import NotificationService
|
||
|
||
try:
|
||
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
|
||
except Lesson.DoesNotExist:
|
||
logger.warning(f'Lesson {lesson_id} not found for notification {notification_type}')
|
||
return f'Lesson {lesson_id} not found'
|
||
|
||
if notification_type == 'lesson_created':
|
||
NotificationService.send_lesson_created(lesson)
|
||
elif notification_type == 'lesson_cancelled':
|
||
NotificationService.send_lesson_cancelled(lesson)
|
||
elif notification_type == 'lesson_reminder':
|
||
NotificationService.send_lesson_reminder(lesson)
|
||
elif notification_type == 'lesson_rescheduled':
|
||
NotificationService.send_lesson_rescheduled(lesson)
|
||
elif notification_type == 'lesson_completed':
|
||
NotificationService.send_lesson_completed(lesson)
|
||
else:
|
||
logger.warning(f'Unknown lesson notification type: {notification_type}')
|
||
return f'Unknown type: {notification_type}'
|
||
|
||
return f'Lesson notification {notification_type} sent for lesson {lesson_id}'
|
||
|
||
|
||
@shared_task
|
||
def send_lesson_reminder(lesson_id, minutes_before=30):
|
||
"""
|
||
Отправка напоминания о занятии.
|
||
|
||
Args:
|
||
lesson_id: ID занятия
|
||
minutes_before: За сколько минут до занятия напоминать
|
||
"""
|
||
from apps.schedule.models import Lesson
|
||
from .services import NotificationService
|
||
|
||
try:
|
||
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
|
||
|
||
# Проверяем не отправили ли уже
|
||
if lesson.reminder_sent:
|
||
return 'Reminder already sent'
|
||
|
||
# Отправляем напоминания
|
||
NotificationService.send_lesson_reminder(lesson)
|
||
|
||
# Отмечаем что напоминание отправлено
|
||
lesson.reminder_sent = True
|
||
# reminder_sent_at временно отключено
|
||
# lesson.reminder_sent_at = timezone.now()
|
||
lesson.save(update_fields=['reminder_sent'])
|
||
|
||
return f'Lesson reminder sent for lesson {lesson_id}'
|
||
|
||
except Lesson.DoesNotExist:
|
||
return f'Lesson {lesson_id} not found'
|
||
except Exception as e:
|
||
logger.error(f'Error sending lesson reminder: {str(e)}')
|
||
return f'Error: {str(e)}'
|