diff --git a/backend/calendar_app/notification_signals.py b/backend/calendar_app/notification_signals.py index 208d2fe3..07ea6567 100644 --- a/backend/calendar_app/notification_signals.py +++ b/backend/calendar_app/notification_signals.py @@ -22,21 +22,48 @@ Employee = get_user_model() @receiver(post_save, sender=CalendarEvent) def create_event_notifications(sender, instance, created, **kwargs): """ - Создает уведомления при создании или изменении события. + Создает уведомления при создании или изменении события асинхронно. Уведомления отправляются: - При создании - всем сотрудникам компании или отдела - При изменении - участникам события """ - event = instance + from django.db import transaction + from notifications.tasks import process_event_notifications_task - if created: - # Новое событие - уведомляем всех участников - notify_event_created(event) - else: - # Проверяем изменение ключевых полей - if hasattr(event, '_changed_fields'): - notify_event_changed(event, event._changed_fields) + def send_task(): + """Отложенная отправка задачи после commit транзакции""" + try: + if created: + # Новое событие + process_event_notifications_task.delay( + event_id=instance.id, + action='created' + ) + else: + # Изменение события + changed_fields = getattr(instance, '_changed_fields', []) + if changed_fields: + process_event_notifications_task.delay( + event_id=instance.id, + action='updated', + changed_fields=changed_fields + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"Failed to queue event notifications task: {e}, " + f"falling back to sync" + ) + # Fallback на старую логику + if created: + notify_event_created(instance) + else: + if hasattr(instance, '_changed_fields'): + notify_event_changed(instance, instance._changed_fields) + + transaction.on_commit(send_task) @receiver(pre_save, sender=CalendarEvent) @@ -69,33 +96,45 @@ def track_event_changes(sender, instance, **kwargs): @receiver(pre_delete, sender=CalendarEvent) def notify_event_cancelled(sender, instance, **kwargs): """ - Создает уведомления при удалении (отмене) события. + Создает уведомления при удалении (отмене) события асинхронно. """ - event = instance + from notifications.tasks import process_event_notifications_task - # Определяем получателей - recipients = get_event_recipients(event) - - # Создаем уведомления об отмене - for recipient in recipients: - NotificationService.create_notification_async( - recipient=recipient, - notification_type_code='event_cancelled', - title='Событие отменено', - message=( - f'Событие "{event.title}" ' - f'({event.start_date.strftime("%d.%m.%Y")}) отменено' - ), - action_url=get_calendar_url(event), - metadata={ - 'event_title': event.title, - 'event_date': event.start_date.isoformat(), - 'department_id': ( - event.department.id if event.department else None - ), - 'is_company_event': event.is_company, - } + try: + # Запускаем задачу синхронно, т.к. pre_delete выполняется до удаления + # и объект ещё существует в БД + process_event_notifications_task.apply_async( + args=[instance.id, 'cancelled'], + countdown=1 # Небольшая задержка чтобы удаление успело произойти ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"Failed to queue event cancellation task: {e}, " + f"falling back to sync" + ) + # Fallback на старую логику + recipients = get_event_recipients(instance) + for recipient in recipients: + NotificationService.create_notification( + recipient=recipient, + notification_type_code='event_cancelled', + title='Событие отменено', + message=( + f'Событие "{instance.title}" ' + f'({instance.start_date.strftime("%d.%m.%Y")}) отменено' + ), + action_url=get_calendar_url(instance), + metadata={ + 'event_title': instance.title, + 'event_date': instance.start_date.isoformat(), + 'department_id': ( + instance.department.id if instance.department else None + ), + 'is_company_event': instance.is_company, + } + ) # ===== Вспомогательные функции ===== diff --git a/backend/communications/MESSENGER_INTEGRATION_GUIDE.md b/backend/communications/MESSENGER_INTEGRATION_GUIDE.md deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/communications/MODERN_CHAT_LIST_REDESIGN.md b/backend/communications/MODERN_CHAT_LIST_REDESIGN.md deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/feed/notification_signals.py b/backend/feed/notification_signals.py index 9279cc10..44eaeaf6 100644 --- a/backend/feed/notification_signals.py +++ b/backend/feed/notification_signals.py @@ -21,7 +21,7 @@ Employee = get_user_model() @receiver(post_save, sender=Post) def create_post_notification(sender, instance, created, **kwargs): """ - Создает уведомления при создании новой публикации. + Создает уведомления при создании новой публикации асинхронно. Уведомления отправляются в зависимости от типа: - TYPE_COMPANY - всем сотрудникам @@ -31,8 +31,27 @@ def create_post_notification(sender, instance, created, **kwargs): if not created: return - post = instance - notify_new_post(post) + from django.db import transaction + from notifications.tasks import process_post_notifications_task + + def send_task(): + """Отложенная отправка задачи после commit транзакции""" + try: + process_post_notifications_task.delay( + post_id=instance.id, + action='created' + ) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"Failed to queue post notifications task: {e}, " + f"falling back to sync" + ) + # Fallback на старую логику + notify_new_post(instance) + + transaction.on_commit(send_task) @receiver(post_save, sender=Comment) diff --git a/backend/notifications/tasks.py b/backend/notifications/tasks.py index 689143f7..b1cbaf5a 100644 --- a/backend/notifications/tasks.py +++ b/backend/notifications/tasks.py @@ -428,3 +428,307 @@ def process_message_notifications_task(self, message_id: int): except Exception as exc: logger.exception(f"Failed to process notifications for message {message_id}: {exc}") raise self.retry(exc=exc) + + +@shared_task( + name="calendar.process_event_notifications", + bind=True, + max_retries=3, + default_retry_delay=30, +) +def process_event_notifications_task( + self, + event_id: int, + action: str, # 'created', 'updated', 'cancelled' + changed_fields: list = None +): + """ + Обрабатывает уведомления для событий календаря асинхронно. + + Args: + event_id: ID события + action: Тип действия ('created', 'updated', 'cancelled') + changed_fields: Список изменённых полей (для updated) + """ + try: + from calendar_app.models import CalendarEvent + from notifications.services import NotificationService + + event = CalendarEvent.objects.select_related( + 'department', 'creator' + ).prefetch_related('participants').get(id=event_id) + + # Определяем получателей + recipients = set() + + if event.is_company: + from employees.models import Employee + recipients = set(Employee.objects.filter(is_active=True).exclude(id=event.creator.id)) + elif event.department: + recipients = set(event.department.employees.filter(is_active=True).exclude(id=event.creator.id)) + else: + recipients = set(event.participants.exclude(id=event.creator.id)) + + if not recipients: + return {"status": "success", "notifications_sent": 0} + + # Определяем тип и текст уведомления + if action == 'created': + notification_type = 'event_created' + title = 'Новое событие в календаре' + message = f'"{event.title}" ({event.start_date.strftime("%d.%m.%Y")})' + elif action == 'cancelled': + notification_type = 'event_cancelled' + title = 'Событие отменено' + message = f'Событие "{event.title}" ({event.start_date.strftime("%d.%m.%Y")}) отменено' + else: # updated + notification_type = 'event_updated' + title = 'Событие изменено' + message = f'Изменено событие "{event.title}"' + + # URL календаря + if event.department: + action_url = f'/calendar/?department={event.department.id}' + else: + action_url = '/calendar/' + + notifications_count = 0 + + # Отправляем уведомления + for recipient in recipients: + NotificationService.create_notification( + recipient=recipient, + notification_type_code=notification_type, + title=title, + message=message, + content_object=event, + action_url=action_url, + metadata={ + 'event_id': event.id, + 'event_title': event.title, + 'event_date': event.start_date.isoformat(), + 'department_id': event.department.id if event.department else None, + 'is_company_event': event.is_company, + } + ) + notifications_count += 1 + + logger.info( + f"Processed event {event_id} ({action}): sent {notifications_count} notifications" + ) + + return { + "status": "success", + "event_id": event_id, + "action": action, + "notifications_sent": notifications_count + } + + except CalendarEvent.DoesNotExist: + logger.error(f"CalendarEvent {event_id} not found") + return {"status": "error", "reason": "event_not_found"} + + except Exception as exc: + logger.exception(f"Failed to process event notifications: {exc}") + raise self.retry(exc=exc) + + +@shared_task( + name="feed.process_post_notifications", + bind=True, + max_retries=3, + default_retry_delay=30, +) +def process_post_notifications_task(self, post_id: int, action: str = 'created'): + """ + Обрабатывает уведомления для публикаций асинхронно. + + Args: + post_id: ID публикации + action: Тип действия ('created', 'comment') + """ + try: + from feed.models import Post + from notifications.services import NotificationService + + post = Post.objects.select_related( + 'author', 'department' + ).get(id=post_id) + + # Определяем получателей + recipients = set() + + if post.type == 'company': + from employees.models import Employee + recipients = set(Employee.objects.filter(is_active=True).exclude(id=post.author.id)) + elif post.type == 'department' and post.department: + recipients = set(post.department.employees.filter(is_active=True).exclude(id=post.author.id)) + + if not recipients: + return {"status": "success", "notifications_sent": 0} + + # Определяем контекст + if post.type == 'company': + context = 'компании' + elif post.type == 'department' and post.department: + context = f'отдела {post.department.name}' + else: + context = f'от {post.author.get_full_name() or post.author.username}' + + notifications_count = 0 + + # Отправляем уведомления + for recipient in recipients: + NotificationService.create_notification( + recipient=recipient, + notification_type_code='feed_new_post', + title=f'Новая публикация {context}', + message=post.title, + content_object=post, + action_url=f'/post/{post.id}/', + metadata={ + 'post_id': post.id, + 'post_title': post.title, + 'author_id': post.author.id, + 'post_type': post.type, + } + ) + notifications_count += 1 + + logger.info( + f"Processed post {post_id}: sent {notifications_count} notifications" + ) + + return { + "status": "success", + "post_id": post_id, + "notifications_sent": notifications_count + } + + except Post.DoesNotExist: + logger.error(f"Post {post_id} not found") + return {"status": "error", "reason": "post_not_found"} + + except Exception as exc: + logger.exception(f"Failed to process post notifications: {exc}") + raise self.retry(exc=exc) + + +@shared_task( + name="requests.process_request_notifications", + bind=True, + max_retries=3, + default_retry_delay=30, +) +def process_request_notifications_task( + self, + request_id: int, + action: str, # 'created', 'approved', 'rejected', 'comment' + comment_id: int = None +): + """ + Обрабатывает уведомления для заявлений асинхронно. + + Args: + request_id: ID заявления + action: Тип действия + comment_id: ID комментария (если action='comment') + """ + try: + from requests_app.models import Request, RequestComment + from notifications.services import NotificationService + + request_obj = Request.objects.select_related( + 'employee' + ).prefetch_related('departments').get(id=request_id) + + recipients = set() + + if action == 'comment' and comment_id: + comment = RequestComment.objects.select_related('author').get(id=comment_id) + + # Уведомляем автора заявления + recipients.add(request_obj.employee) + + # Уведомляем руководителей отделов + for dept in request_obj.departments.all(): + dept_heads = dept.employees.filter( + departments_links__role='head', + departments_links__is_active=True, + is_active=True + ) + recipients.update(dept_heads) + + # Исключаем автора комментария + recipients.discard(comment.author) + + notification_type = 'request_comment' + title = f"💬 Новый комментарий к заявлению от {request_obj.employee.get_full_name()}" + message = f"{comment.author.get_full_name()} прокомментировал: {comment.text[:100]}" + + elif action == 'approved': + recipients = {request_obj.employee} + notification_type = 'request_approved' + title = f"✅ Заявление одобрено" + message = f'Ваше заявление "{request_obj.get_type_display()}" одобрено' + + elif action == 'rejected': + recipients = {request_obj.employee} + notification_type = 'request_rejected' + title = f"❌ Заявление отклонено" + message = f'Ваше заявление "{request_obj.get_type_display()}" отклонено' + + else: # created + # Уведомляем руководителей + for dept in request_obj.departments.all(): + dept_heads = dept.employees.filter( + departments_links__role='head', + departments_links__is_active=True, + is_active=True + ) + recipients.update(dept_heads) + + notification_type = 'request_new' + title = f"📋 Новое заявление от {request_obj.employee.get_full_name()}" + message = f'Тип: {request_obj.get_type_display()}' + + if not recipients: + return {"status": "success", "notifications_sent": 0} + + notifications_count = 0 + + # Отправляем уведомления + for recipient in recipients: + NotificationService.create_notification( + recipient=recipient, + notification_type_code=notification_type, + title=title, + message=message, + content_object=request_obj, + action_url=f'/requests/{request_obj.id}/', + metadata={ + 'request_id': request_obj.id, + 'request_type': request_obj.type, + 'employee_id': request_obj.employee.id, + } + ) + notifications_count += 1 + + logger.info( + f"Processed request {request_id} ({action}): sent {notifications_count} notifications" + ) + + return { + "status": "success", + "request_id": request_id, + "action": action, + "notifications_sent": notifications_count + } + + except Request.DoesNotExist: + logger.error(f"Request {request_id} not found") + return {"status": "error", "reason": "request_not_found"} + + except Exception as exc: + logger.exception(f"Failed to process request notifications: {exc}") + raise self.retry(exc=exc)