uchill/backend/apps/notifications/tasks.py

590 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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:
action_button = ''
if notification.action_url:
action_url = f"{settings.FRONTEND_URL}{notification.action_url}"
action_button = f'''
<tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{action_url}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Перейти
</a>
</td>
</tr>
</table>
</td>
</tr>
'''
email_body = f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--[if mso]>
<style type="text/css">
body, table, td {{font-family: Arial, sans-serif !important;}}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">{notification.title}</h1>
</td>
</tr>
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">{notification.message}</p>
</td>
</tr>
{action_button}
</table>
</td>
</tr>
<tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
else:
# Если есть шаблон, оборачиваем его в базовую HTML структуру, если нужно
if not email_body.strip().startswith('<html'):
action_button = ''
if notification.action_url:
action_url = f"{settings.FRONTEND_URL}{notification.action_url}"
action_button = f'''
<tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{action_url}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Перейти
</a>
</td>
</tr>
</table>
</td>
</tr>
'''
email_body = f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--[if mso]>
<style type="text/css">
body, table, td {{font-family: Arial, sans-serif !important;}}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 24px;">
{email_body}
</td>
</tr>
{action_button}
</table>
</td>
</tr>
<tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
except NotificationTemplate.DoesNotExist:
# Если шаблона нет, используем дефолтный
email_subject = notification.title
action_button = ''
if notification.action_url:
action_url = f"{settings.FRONTEND_URL}{notification.action_url}"
action_button = f'''
<tr>
<td style="padding-top: 8px; padding-bottom: 24px; text-align: center;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td style="background-color: #7444FD; border-radius: 4px;">
<a href="{action_url}" style="display: inline-block; padding: 14px 32px; font-size: 16px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 4px;">
Перейти
</a>
</td>
</tr>
</table>
</td>
</tr>
'''
email_body = f"""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--[if mso]>
<style type="text/css">
body, table, td {{font-family: Arial, sans-serif !important;}}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center;">
<!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 40px 40px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 24px;">
<h1 style="margin: 0; font-size: 28px; font-weight: 500; color: #212121; line-height: 1.2;">{notification.title}</h1>
</td>
</tr>
<tr>
<td style="padding-bottom: 24px;">
<p style="margin: 0; font-size: 16px; color: #424242; line-height: 1.6;">{notification.message}</p>
</td>
</tr>
{action_button}
</table>
</td>
</tr>
<tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© 2026 Uchill. Все права защищены.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</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)}'