"""
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'''
|
|
'''
email_body = f"""
|
|
{notification.title}
|
|
{notification.message}
|
{action_button}
|
|
С уважением, Команда Uchill
© 2026 Uchill. Все права защищены.
|
|
|
"""
else:
# Если есть шаблон, оборачиваем его в базовую HTML структуру, если нужно
if not email_body.strip().startswith('
|
'''
email_body = f"""
|
|
|
{email_body}
|
{action_button}
|
|
С уважением, Команда Uchill
© 2026 Uchill. Все права защищены.
|
|
|
"""
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'''
|
|
'''
email_body = f"""
|
|
{notification.title}
|
|
{notification.message}
|
{action_button}
|
|
С уважением, Команда Uchill
© 2026 Uchill. Все права защищены.
|
|
|
"""
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 уведомления."""
import re
import asyncio
from urllib.parse import urlparse
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"{notification.title}\n\n{notification.message}"
full_url = None
if notification.action_url:
full_url = f"{settings.FRONTEND_URL}{notification.action_url}"
frontend_domain = urlparse(settings.FRONTEND_URL).netloc or settings.FRONTEND_URL
message_text += f'\n\n{frontend_domain}'
# Уведомления от ИИ для ментора — с кнопками управления
reply_markup = None
is_mentor_ai_homework = (
getattr(notification.recipient, 'role', None) == 'mentor'
and notification.notification_type in ('homework_submitted', 'homework_reviewed')
and notification.action_url
and ('ИИ' in (notification.title or '') or 'черновик' in (notification.title or '').lower())
)
if is_mentor_ai_homework:
match = re.match(r'/homework/(\d+)/submissions/(\d+)/?', notification.action_url.strip())
if match:
submission_id = match.group(2)
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
keyboard = [
[InlineKeyboardButton("📝 Открыть решение", callback_data=f"mentor_submission_{submission_id}")],
]
if full_url:
keyboard.append([InlineKeyboardButton("🌐 Открыть на сайте", url=full_url)])
reply_markup = InlineKeyboardMarkup(keyboard)
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if reply_markup:
from .telegram_bot import send_telegram_message_with_buttons
success = loop.run_until_complete(
send_telegram_message_with_buttons(
telegram_id, message_text, reply_markup, parse_mode='HTML'
)
)
else:
from .telegram_bot import send_telegram_message
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
def _format_lesson_datetime_ru(dt, user_timezone='UTC'):
"""Форматирует дату/время для русского языка: «23 февраля 2026, 14:30»."""
if dt is None:
return '—'
from apps.users.utils import convert_to_user_timezone
local_dt = convert_to_user_timezone(dt, user_timezone)
months_ru = (
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
)
day = local_dt.day
month = months_ru[local_dt.month - 1]
year = local_dt.year
time_str = local_dt.strftime('%H:%M')
return f'{day} {month} {year}, {time_str}'
@shared_task
def send_lesson_completion_confirmation_telegram(lesson_id, only_if_someone_not_connected=False):
"""
Отправить ментору в Telegram сообщение о завершённом занятии
с кнопками «Занятие состоялось» / «Занятие отменилось».
only_if_someone_not_connected: при True — отправить только если ментор или ученик не подключались
(при авто-завершении Celery). При False — всегда отправить (ручное завершение, сигнал).
"""
import asyncio
from apps.schedule.models import Lesson
from apps.video.models import VideoRoom
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from .telegram_bot import send_telegram_message_with_buttons
logger.info(f'send_lesson_completion_confirmation_telegram: lesson_id={lesson_id}')
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 completion confirmation')
return
mentor = lesson.mentor
if not mentor:
logger.warning(f'Lesson {lesson_id}: mentor is None, skipping completion confirmation')
return
if not mentor.telegram_id:
logger.warning(
f'Lesson {lesson_id}: mentor {mentor.id} has no telegram_id linked, skipping completion confirmation'
)
return
tz = mentor.timezone or 'UTC'
student_name = ''
if lesson.client and lesson.client.user:
student_name = lesson.client.user.get_full_name() or lesson.client.user.email or 'Ученик'
else:
student_name = 'Ученик'
start_str = _format_lesson_datetime_ru(lesson.start_time, tz)
end_str = _format_lesson_datetime_ru(lesson.end_time, tz)
# Подключения: из Lesson или VideoRoom
mentor_connected = lesson.mentor_connected_at is not None
client_connected = lesson.client_connected_at is not None
if not mentor_connected and not client_connected:
try:
vr = VideoRoom.objects.filter(lesson=lesson).first()
if vr:
mentor_connected = vr.mentor_joined_at is not None
client_connected = vr.client_joined_at is not None
except Exception:
pass
mentor_status = '✅ Подключился' if mentor_connected else '❌ Не подключался'
client_status = '✅ Подключился' if client_connected else '❌ Не подключался'
someone_not_connected = not mentor_connected or not client_connected
if only_if_someone_not_connected and not someone_not_connected:
logger.info(f'Lesson {lesson_id}: both participants connected, skipping completion confirmation')
return
message = (
f"⏱ Занятие завершилось по времени\n\n"
f"📚 {lesson.title}\n"
f"👤 {student_name}\n\n"
f"🕐 Время: {start_str} — {end_str}\n\n"
f"📡 Подключения:\n"
f" • Ментор: {mentor_status}\n"
f" • Ученик: {client_status}\n\n"
f"Подтвердите, пожалуйста:"
)
keyboard = [
[
InlineKeyboardButton("✅ Занятие состоялось", callback_data=f"lesson_confirm_{lesson_id}"),
InlineKeyboardButton("❌ Занятие отменилось", callback_data=f"lesson_cancel_{lesson_id}"),
]
]
reply_markup = InlineKeyboardMarkup(keyboard)
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
success = loop.run_until_complete(
send_telegram_message_with_buttons(
mentor.telegram_id, message, reply_markup, parse_mode='HTML'
)
)
loop.close()
if success:
logger.info(f'Lesson {lesson_id} completion confirmation sent to mentor {mentor.id}')
except Exception as e:
logger.error(f'Error sending lesson completion confirmation to mentor: {e}', exc_info=True)
@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':
# Проверяем что напоминание ещё не отправлено (используем флаг для 1 часа как основной)
if lesson.reminder_1h_sent:
logger.info(f'Lesson {lesson_id} reminder already sent, skipping')
return f'Lesson {lesson_id} reminder already sent'
NotificationService.send_lesson_reminder(lesson)
# Отмечаем что напоминание отправлено
lesson.reminder_1h_sent = True
lesson.save(update_fields=['reminder_1h_sent'])
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)}'