Corp/backend/notifications/task_base.py
Игорь Надеин c6002cff1b fix: исправлены ошибки в системе уведомлений
- Добавлен эндпоинт удаления комментариев к заявкам (DELETE /api/v1/requests/{pk}/comments/{comment_id}/)
- Исправлены references на переменную в сигналах комментариев (comment -> instance)
- Добавлена обработка статуса cancelled для заявок в notification_type_map
- Обновлены процессоры уведомлений в task_base.py (правильные поля моделей)
- Создан скрипт генерации тестовых пользователей (scripts/create_test_users.py)
2026-01-09 16:57:48 +03:00

608 lines
25 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 задач уведомлений.
Предоставляет общую логику для обработки уведомлений,
чтобы избежать дублирования кода в tasks.py
"""
import logging
from typing import Set, Optional, Dict, Any
from django.contrib.auth import get_user_model
from celery import Task
logger = logging.getLogger(__name__)
User = get_user_model()
class BaseNotificationProcessor:
"""
Базовый класс для процессоров уведомлений.
Содержит общую логику определения получателей и отправки уведомлений.
"""
def __init__(self, task: Task = None):
"""
Args:
task: Celery task для retry при ошибках
"""
self.task = task
self.logger = logging.getLogger(self.__class__.__name__)
def send_notifications(
self,
recipients: Set[User],
notification_type: str,
title: str,
message: str,
content_object=None,
action_url: str = '',
metadata: Optional[Dict[str, Any]] = None
) -> int:
"""
Отправляет уведомления списку получателей.
Args:
recipients: Набор получателей
notification_type: Код типа уведомления
title: Заголовок
message: Текст сообщения
content_object: Связанный объект
action_url: URL для действия
metadata: Дополнительные данные
Returns:
Количество отправленных уведомлений
"""
from notifications.services import NotificationService
count = 0
for recipient in recipients:
try:
NotificationService.create_notification(
recipient=recipient,
notification_type_code=notification_type,
title=title,
message=message,
content_object=content_object,
action_url=action_url,
metadata=metadata or {}
)
count += 1
except Exception as e:
self.logger.error(
f"Failed to send notification to user {recipient.id}: {e}"
)
return count
def get_active_employees(self, exclude_ids: Set[int] = None) -> Set[User]:
"""
Получает всех активных сотрудников.
Args:
exclude_ids: ID пользователей для исключения
Returns:
Набор активных сотрудников
"""
queryset = User.objects.filter(is_active=True)
if exclude_ids:
queryset = queryset.exclude(id__in=exclude_ids)
return set(queryset)
def get_department_employees(
self,
department_ids: list,
exclude_ids: Set[int] = None
) -> Set[User]:
"""
Получает сотрудников отделов.
Args:
department_ids: Список ID отделов
exclude_ids: ID пользователей для исключения
Returns:
Набор сотрудников отделов
"""
queryset = User.objects.filter(
departments_links__department_id__in=department_ids,
departments_links__is_active=True,
is_active=True
).distinct()
if exclude_ids:
queryset = queryset.exclude(id__in=exclude_ids)
return set(queryset)
def log_result(
self,
entity_type: str,
entity_id: int,
action: str,
notifications_sent: int
) -> Dict[str, Any]:
"""
Логирует результат обработки и возвращает словарь результата.
Args:
entity_type: Тип сущности (message, event, post и т.д.)
entity_id: ID сущности
action: Выполненное действие
notifications_sent: Количество отправленных уведомлений
Returns:
Словарь с результатом операции
"""
self.logger.info(
f"Processed {entity_type} {entity_id} ({action}): "
f"sent {notifications_sent} notifications"
)
return {
"status": "success",
f"{entity_type}_id": entity_id,
"action": action,
"notifications_sent": notifications_sent
}
def handle_not_found(
self,
entity_type: str,
entity_id: int
) -> Dict[str, Any]:
"""
Обрабатывает случай когда сущность не найдена.
Args:
entity_type: Тип сущности
entity_id: ID сущности
Returns:
Словарь с ошибкой
"""
self.logger.error(f"{entity_type.capitalize()} {entity_id} not found")
return {
"status": "error",
"reason": f"{entity_type}_not_found"
}
def handle_exception(self, exc: Exception):
"""
Обрабатывает исключение - логирует и делает retry.
Args:
exc: Исключение для обработки
"""
self.logger.exception(f"Failed to process notifications: {exc}")
if self.task:
raise self.task.retry(exc=exc)
else:
raise
class MessageNotificationProcessor(BaseNotificationProcessor):
"""Процессор уведомлений для сообщений в чатах"""
def process(self, message_id: int) -> Dict[str, Any]:
"""
Обрабатывает уведомления для сообщения.
Args:
message_id: ID сообщения
Returns:
Результат обработки
"""
try:
from communications.models import Message
from communications.notification_signals import (
get_users_with_notifications_enabled,
extract_mentions,
truncate_message,
get_chat_name
)
# Загружаем сообщение
message = Message.objects.select_related(
'chat', 'author', 'reply_to__author'
).get(id=message_id)
if message.is_system or message.is_deleted:
return {"status": "skipped", "reason": "system_or_deleted"}
chat = message.chat
author = message.author
content = message.content
# Получаем участников
if chat.type in ['announcement', 'channel', 'department', 'global']:
participants = chat.get_participants.exclude(id=author.id)
else:
participants = chat.participants.exclude(id=author.id)
participants_list = list(participants)
if not participants_list:
return {"status": "success", "notifications_sent": 0}
# Настройки уведомлений
notification_settings = get_users_with_notifications_enabled(
chat, participants_list
)
notifications_count = 0
mentioned_user_ids = []
# 1. Упоминания
mentioned_users = extract_mentions(content)
if mentioned_users:
for email in mentioned_users:
try:
user = User.objects.get(email=email)
if user in participants_list and user.id != author.id:
if notification_settings.get(user.id, True):
notifications_count += self.send_notifications(
{user},
'chat_mention',
f'Вас упомянул {author.get_full_name() or author.username}',
truncate_message(content, 100),
message,
f'/communications/chats/{chat.id}/?message={message.id}',
{
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': message.id,
'author_id': author.id,
}
)
mentioned_user_ids.append(user.id)
except User.DoesNotExist:
continue
# 2. Ответы на сообщения
if message.reply_to and message.reply_to.author_id != author.id:
original_author = message.reply_to.author
if original_author.id not in mentioned_user_ids:
if notification_settings.get(original_author.id, True):
notifications_count += self.send_notifications(
{original_author},
'chat_reply',
f'{author.get_full_name() or author.username} ответил на ваше сообщение',
truncate_message(content, 100),
message,
f'/communications/chats/{chat.id}/?message={message.id}',
{
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': message.id,
'reply_to_id': message.reply_to.id,
'author_id': author.id,
}
)
# 3. Обычные уведомления
excluded_ids = set(mentioned_user_ids)
if message.reply_to and message.reply_to.author_id != author.id:
excluded_ids.add(message.reply_to.author_id)
if chat.type == 'announcement':
notification_type = 'announcement_new_message'
title = f'Новое объявление от {author.get_full_name() or author.username}'
is_announcement = True
else:
notification_type = 'chat_new_message'
if chat.type == 'private':
title = f'Новое сообщение от {author.get_full_name() or author.username}'
else:
title = f'{author.get_full_name() or author.username} в {get_chat_name(chat)}'
is_announcement = False
for recipient in participants_list:
if recipient.id in excluded_ids:
continue
if not notification_settings.get(recipient.id, True):
continue
metadata = {
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': message.id,
'author_id': author.id,
}
if is_announcement:
metadata['is_announcement'] = True
notifications_count += self.send_notifications(
{recipient},
notification_type,
title,
truncate_message(content, 150 if is_announcement else 100),
message,
f'/communications/chats/{chat.id}/',
metadata
)
return self.log_result('message', message_id, 'created', notifications_count)
except Message.DoesNotExist:
return self.handle_not_found('message', message_id)
except Exception as exc:
self.handle_exception(exc)
def truncate_text(text: str, max_length: int = 100) -> str:
"""
Обрезает текст до указанной длины, добавляя троеточие.
Args:
text: Исходный текст
max_length: Максимальная длина
Returns:
Обрезанный текст
"""
if not text or len(text) <= max_length:
return text or ''
return text[:max_length].rstrip() + '...'
class EventNotificationProcessor(BaseNotificationProcessor):
"""Процессор уведомлений о событиях в календаре"""
def process(self, event_id: int) -> Dict[str, Any]:
"""Обработка уведомлений для события календаря"""
try:
from calendar_app.models import CalendarEvent
from django.contrib.auth import get_user_model
User = get_user_model()
event = CalendarEvent.objects.select_related('created_by', 'employee', 'department').get(id=event_id)
# Собираем всех получателей
recipients = set()
# Если событие отдела - уведомляем всех сотрудников отдела
if event.department:
recipients.update(self.get_department_employees([event.department.id]))
# Если личное событие сотрудника - уведомляем только его
elif event.employee:
# Проверяем что сотрудник активен
if event.employee.is_active and event.employee.email_verified:
recipients.add(event.employee)
# Если глобальное событие (нет department и employee) - уведомляем всех активных
else:
recipients = set(User.objects.filter(is_active=True, email_verified=True))
# Исключаем создателя
if event.created_by:
recipients.discard(event.created_by)
if not recipients:
return self.log_result('event', event_id, 'skipped', 0)
# Отправляем уведомления
notifications_sent = self.send_notifications(
recipients=recipients,
notification_type='event_created',
title=f'Новое событие: {event.title}',
message=truncate_text(event.description or '', 150),
content_object=event,
action_url=f'/calendar/?event={event.id}',
metadata={
'event_id': event.id,
'event_title': event.title,
'start_time': event.start_time.isoformat() if event.start_time else None,
'end_time': event.end_time.isoformat() if event.end_time else None,
}
)
return self.log_result('event', event_id, 'created', notifications_sent)
except Exception as exc:
self.handle_exception(exc)
class PostNotificationProcessor(BaseNotificationProcessor):
"""Процессор уведомлений о постах в ленте"""
def process(self, post_id: int) -> Dict[str, Any]:
"""Обработка уведомлений для поста"""
try:
from feed.models import Post
from django.contrib.auth import get_user_model
User = get_user_model()
post = Post.objects.select_related('author', 'department').get(id=post_id)
# Собираем получателей
recipients = set()
# Если пост для конкретного отдела - уведомляем сотрудников отдела
if post.department:
recipients.update(self.get_department_employees([post.department.id]))
else:
# Иначе уведомляем всех активных сотрудников
recipients = set(User.objects.filter(is_active=True, email_verified=True))
# Исключаем автора
if post.author:
recipients.discard(post.author)
if not recipients:
return self.log_result('post', post_id, 'skipped', 0)
# Отправляем уведомления
notifications_sent = self.send_notifications(
recipients=recipients,
notification_type='feed_new_post',
title=f'{post.title}',
message=truncate_text(post.body, 200),
content_object=post,
action_url=f'/feed/?post={post.id}',
metadata={
'post_id': post.id,
'author_id': post.author.id if post.author else None,
'post_type': post.type,
}
)
return self.log_result('post', post_id, 'created', notifications_sent)
except Exception as exc:
self.handle_exception(exc)
class RequestNotificationProcessor(BaseNotificationProcessor):
"""Процессор уведомлений о заявках"""
def process(self, request_id: int, notification_type: str = 'created') -> Dict[str, Any]:
"""
Обработка уведомлений для заявки
Args:
request_id: ID заявки
notification_type: Тип уведомления (created, status_changed, comment_added)
"""
try:
from requests_app.models import Request
request_obj = Request.objects.select_related('employee', 'approver').prefetch_related('recipients', 'cc_users').get(id=request_id)
# Собираем получателей в зависимости от типа уведомления
recipients = set()
if notification_type == 'created':
# Уведомляем согласующего и получателей
if request_obj.approver:
recipients.add(request_obj.approver)
for user in request_obj.recipients.all():
if user.is_active and getattr(user, 'email_verified', True):
recipients.add(user)
for user in request_obj.cc_users.all():
if user.is_active and getattr(user, 'email_verified', True):
recipients.add(user)
elif notification_type == 'status_changed':
# Уведомляем создателя заявки
if request_obj.employee:
if request_obj.employee.is_active and getattr(request_obj.employee, 'email_verified', True):
recipients.add(request_obj.employee)
elif notification_type == 'comment_added':
# Уведомляем всех участников
if request_obj.employee:
if request_obj.employee.is_active and getattr(request_obj.employee, 'email_verified', True):
recipients.add(request_obj.employee)
if request_obj.approver:
if request_obj.approver.is_active and getattr(request_obj.approver, 'email_verified', True):
recipients.add(request_obj.approver)
for user in request_obj.recipients.all():
if user.is_active and getattr(user, 'email_verified', True):
recipients.add(user)
for user in request_obj.cc_users.all():
if user.is_active and getattr(user, 'email_verified', True):
recipients.add(user)
if not recipients:
return self.log_result('request', request_id, notification_type, 0)
# Формируем заголовок
title_map = {
'created': f'Новая заявка #{request_obj.id}: {request_obj.title or request_obj.get_type_display()}',
'status_changed': f'Изменён статус заявки #{request_obj.id}',
'comment_added': f'Новый комментарий к заявке #{request_obj.id}',
}
type_code_map = {
'created': 'request_new',
'status_changed': 'request_status_changed',
'comment_added': 'request_comment',
}
# Отправляем уведомления
notifications_sent = self.send_notifications(
recipients=recipients,
notification_type=type_code_map.get(notification_type, 'request_new'),
title=title_map.get(notification_type, f'Обновление заявки #{request_obj.id}'),
message=truncate_text(request_obj.comment or '', 150),
content_object=request_obj,
action_url=f'/requests/{request_obj.id}/',
metadata={
'request_id': request_obj.id,
'request_type': request_obj.type,
'status': request_obj.status,
}
)
return self.log_result('request', request_id, notification_type, notifications_sent)
except Exception as exc:
self.handle_exception(exc)
class DocumentNotificationProcessor(BaseNotificationProcessor):
"""Процессор уведомлений о документах"""
def process(self, document_id: int) -> Dict[str, Any]:
"""Обработка уведомлений для документа"""
try:
from documents.models import Document
from django.contrib.auth import get_user_model
User = get_user_model()
document = Document.objects.select_related('uploaded_by').prefetch_related('departments', 'recipients').get(id=document_id)
# Собираем получателей
recipients = set()
# Если разослать всем
if document.sent_to_all:
recipients = set(User.objects.filter(is_active=True, email_verified=True))
else:
# Департаменты
if document.departments.exists():
dept_ids = [d.id for d in document.departments.all()]
recipients.update(self.get_department_employees(dept_ids))
# Конкретные получатели
for user in document.recipients.all():
if user.is_active and getattr(user, 'email_verified', True):
recipients.add(user)
# Исключаем автора
if document.uploaded_by:
recipients.discard(document.uploaded_by)
if not recipients:
return self.log_result('document', document_id, 'skipped', 0)
# Отправляем уведомления
notifications_sent = self.send_notifications(
recipients=recipients,
notification_type='document_ready',
title=f'Новый документ: {document.title}',
message=truncate_text(document.description or '', 150),
content_object=document,
action_url=f'/documents/{document.id}/',
metadata={
'document_id': document.id,
'document_title': document.title,
}
)
return self.log_result('document', document_id, 'created', notifications_sent)
except Exception as exc:
self.handle_exception(exc)