From 7e410ebff8105ef2f5ef5ff3c7225f67c735e9be Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 13:05:58 +0300 Subject: [PATCH] =?UTF-8?q?refactor(feed):=20=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20notifications=20=D0=B2=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - Монолитный notification_signals.py (~200 строк) смешивал разные concerns - Сложно поддерживать и понимать логику уведомлений - Отсутствовала интеграция с API для уведомлений о лайках Решение: Разделен на модульную структуру feed/notifications/: - config.py - NotificationVerbs, MessageTemplates, ActionURLs, truncate_text - handlers.py - notify_new_post(), notify_comment(), notify_post_reaction(), get_post_recipients() - signals.py - Django signal receivers (@receiver) - __init__.py - публичный API модуля Изменения: - Создан: feed/notifications/ модуль с 4 файлами - Переименован: notification_signals.py → notification_signals.py.old - Изменен: apps.py - обновлен импорт на feed.notifications - Изменен: api/v1/feed/views.py - добавлен вызов notify_post_reaction() в метод like() Преимущества: - ✅ Единообразие с requests_app и procurement - ✅ Четкое разделение ответственности (SRP) - ✅ Легче тестировать отдельные компоненты - ✅ Публичный API через __init__.py - ✅ Уведомления о лайках теперь работают - ✅ Централизованные шаблоны сообщений Верификация: - python manage.py check - успешно --- backend/api/v1/feed/views.py | 3 + backend/feed/apps.py | 4 +- ...signals.py => notification_signals.py.old} | 0 backend/feed/notifications/__init__.py | 29 ++++ backend/feed/notifications/config.py | 93 ++++++++++ backend/feed/notifications/handlers.py | 162 ++++++++++++++++++ backend/feed/notifications/signals.py | 44 +++++ 7 files changed, 333 insertions(+), 2 deletions(-) rename backend/feed/{notification_signals.py => notification_signals.py.old} (100%) create mode 100644 backend/feed/notifications/__init__.py create mode 100644 backend/feed/notifications/config.py create mode 100644 backend/feed/notifications/handlers.py create mode 100644 backend/feed/notifications/signals.py diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index 97fe52ac..510e1cb2 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -7,6 +7,7 @@ from django.db.models import BooleanField, Count, Exists, F, OuterRef, Subquery, from employees.constants import DeptPerm from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE from feed.models import Comment, Post, PostLike +from feed.notifications import notify_post_reaction from rest_framework import filters, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -227,6 +228,8 @@ class PostViewSet(viewsets.ModelViewSet): _, created = PostLike.objects.get_or_create(post=post, user=request.user) if created: Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1) + # Отправляем уведомление автору публикации + notify_post_reaction(post, request.user) post.refresh_from_db(fields=["likes_count"]) return Response( {"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK diff --git a/backend/feed/apps.py b/backend/feed/apps.py index 4d10c85c..a71f4abf 100644 --- a/backend/feed/apps.py +++ b/backend/feed/apps.py @@ -6,6 +6,6 @@ class FeedConfig(AppConfig): name = 'feed' def ready(self): - # Подключаем signals для уведомлений - import feed.notification_signals # noqa + # Подключаем модуль уведомлений (signals регистрируются автоматически) + import feed.notifications # noqa import feed.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/feed/notification_signals.py b/backend/feed/notification_signals.py.old similarity index 100% rename from backend/feed/notification_signals.py rename to backend/feed/notification_signals.py.old diff --git a/backend/feed/notifications/__init__.py b/backend/feed/notifications/__init__.py new file mode 100644 index 00000000..ca3eca8c --- /dev/null +++ b/backend/feed/notifications/__init__.py @@ -0,0 +1,29 @@ +""" +Модуль уведомлений для Feed приложения. + +Публичный API: + - NotificationVerbs - константы глаголов уведомлений + - MessageTemplates - шаблоны сообщений + - ActionURLs - генераторы URL-адресов + - notify_post_reaction - функция для уведомления о реакциях (вызывается из views) + +Использование: + from feed.notifications import notify_post_reaction + + # В view при добавлении лайка: + notify_post_reaction(post, request.user) +""" + +from .config import NotificationVerbs, MessageTemplates, ActionURLs +from .handlers import notify_post_reaction + +# Импортируем handlers и signals для регистрации +from . import handlers # noqa: F401 +from . import signals # noqa: F401 + +__all__ = [ + 'NotificationVerbs', + 'MessageTemplates', + 'ActionURLs', + 'notify_post_reaction', +] diff --git a/backend/feed/notifications/config.py b/backend/feed/notifications/config.py new file mode 100644 index 00000000..d4b897ca --- /dev/null +++ b/backend/feed/notifications/config.py @@ -0,0 +1,93 @@ +""" +Конфигурация уведомлений для модуля Feed. + +Содержит: +- NotificationVerbs - глаголы для уведомлений +- MessageTemplates - шаблоны сообщений +- ActionURLs - URL-адреса действий +""" + + +class NotificationVerbs: + """Глаголы (verbs) для уведомлений feed.""" + + NEW_POST = 'feed_new_post' + POST_COMMENT = 'feed_post_comment' + POST_REACTION = 'feed_post_reaction' + + +class MessageTemplates: + """Шаблоны сообщений для уведомлений feed.""" + + @staticmethod + def new_post_company() -> str: + """Шаблон для новости компании.""" + return 'Новая публикация компании' + + @staticmethod + def new_post_department(department_name: str) -> str: + """Шаблон для новости отдела.""" + return f'Новая публикация отдела {department_name}' + + @staticmethod + def new_post_employee(author_name: str) -> str: + """Шаблон для личной публикации.""" + return f'Новая публикация от {author_name}' + + @staticmethod + def comment(author_name: str, comment_text: str) -> str: + """Шаблон для комментария к публикации.""" + return f'{author_name} прокомментировал: {comment_text}' + + @staticmethod + def comment_title() -> str: + """Заголовок для уведомления о комментарии.""" + return 'Новый комментарий к вашей публикации' + + @staticmethod + def reaction(user_name: str, post_title: str) -> str: + """Шаблон для реакции на публикацию.""" + return f'{user_name} понравилась публикация "{post_title}"' + + @staticmethod + def reaction_title() -> str: + """Заголовок для уведомления о реакции.""" + return 'Ваша публикация понравилась' + + +class ActionURLs: + """URL-адреса для действий с публикациями.""" + + # Пока все действия ведут на главную страницу + # TODO: реализовать прямые ссылки на публикации + FEED_HOME = '/' + + @staticmethod + def post_detail(post_id: int) -> str: + """ + Возвращает URL-адрес публикации. + + TODO: реализовать отдельную страницу публикации + """ + return f'/?post={post_id}' # Временное решение + + +def truncate_text(text: str, max_length: int = 100) -> str: + """ + Обрезает текст до указанной длины. + + Args: + text: Исходный текст + max_length: Максимальная длина (по умолчанию 100) + + Returns: + Обрезанный текст с многоточием если нужно + """ + if not text: + return '' + + text = text.strip() + if len(text) <= max_length: + return text + + return text[:max_length - 3] + '...' diff --git a/backend/feed/notifications/handlers.py b/backend/feed/notifications/handlers.py new file mode 100644 index 00000000..1d045929 --- /dev/null +++ b/backend/feed/notifications/handlers.py @@ -0,0 +1,162 @@ +""" +Бизнес-логика отправки уведомлений для модуля Feed. + +Функции: +- notify_new_post - уведомление о новой публикации +- notify_comment - уведомление о комментарии +- notify_post_reaction - уведомление о реакции (лайке) +- get_post_recipients - определение получателей уведомлений +""" + +from django.contrib.auth import get_user_model +from notifications.signals import notify + +from ..constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE +from .config import ( + NotificationVerbs, + MessageTemplates, + ActionURLs, + truncate_text, +) + +Employee = get_user_model() + + +def notify_new_post(post): + """ + Отправляет уведомления о новой публикации. + + Args: + post: Экземпляр модели Post + """ + recipients = get_post_recipients(post) + + # Исключаем автора + recipients = [r for r in recipients if r.id != post.author.id] + + # Определяем заголовок в зависимости от типа публикации + if post.type == TYPE_COMPANY: + title = MessageTemplates.new_post_company() + elif post.type == TYPE_DEPARTMENT and post.department: + title = MessageTemplates.new_post_department(post.department.name) + else: + author_name = post.author.get_full_name() or post.author.username + title = MessageTemplates.new_post_employee(author_name) + + # Отправляем уведомление каждому получателю + for recipient in recipients: + notify.send( + sender=post.author, + recipient=recipient, + verb=NotificationVerbs.NEW_POST, + action_object=post, + description=post.title, + action_url=ActionURLs.FEED_HOME, + data={ + 'title': title, + 'post_id': post.id, + 'post_type': post.type, + 'post_title': post.title, + 'author_id': post.author.id, + 'department_id': ( + post.department.id if post.department else None + ), + } + ) + + +def notify_comment(comment): + """ + Отправляет уведомление автору публикации о новом комментарии. + + Args: + comment: Экземпляр модели Comment + """ + post = comment.post + comment_author = comment.author + + # Уведомляем автора публикации (если комментарий не от него) + if post.author.id == comment_author.id: + return + + author_name = comment_author.get_full_name() or comment_author.username + comment_text = truncate_text(comment.text, 80) + + notify.send( + sender=comment_author, + recipient=post.author, + verb=NotificationVerbs.POST_COMMENT, + action_object=post, + description=MessageTemplates.comment(author_name, comment_text), + action_url=ActionURLs.FEED_HOME, + data={ + 'title': MessageTemplates.comment_title(), + 'post_id': post.id, + 'post_title': post.title, + 'comment_id': comment.id, + 'author_id': comment_author.id, + } + ) + + +def notify_post_reaction(post, user): + """ + Создает уведомление о реакции (лайке) на публикацию. + + Вызывается вручную из view, так как лайки не имеют отдельной модели. + + Args: + post: Публикация, которую лайкнули + user: Пользователь, который поставил лайк + """ + # Уведомляем автора (если лайк не от него) + if post.author.id == user.id: + return + + user_name = user.get_full_name() or user.username + + notify.send( + sender=user, + recipient=post.author, + verb=NotificationVerbs.POST_REACTION, + action_object=post, + description=MessageTemplates.reaction(user_name, post.title), + action_url=ActionURLs.FEED_HOME, + data={ + 'title': MessageTemplates.reaction_title(), + 'post_id': post.id, + 'post_title': post.title, + 'user_id': user.id, + 'reaction_type': 'like', + } + ) + + +def get_post_recipients(post): + """ + Возвращает список получателей уведомлений для публикации. + + Args: + post: Экземпляр модели Post + + Returns: + Список пользователей (Employee), которые должны получить уведомление: + - TYPE_COMPANY - все активные сотрудники + - TYPE_DEPARTMENT - сотрудники отдела + - TYPE_EMPLOYEE - подписчики (пока не реализовано) + """ + if post.type == TYPE_COMPANY: + # Новость компании - всем активным сотрудникам + return list(Employee.objects.filter(is_active=True)) + + elif post.type == TYPE_DEPARTMENT and post.department: + # Новость отдела - сотрудникам отдела + return list(post.department.employees.filter(is_active=True)) + + elif post.type == TYPE_EMPLOYEE: + # Личная публикация - подписчикам + # TODO: реализовать систему подписок + # Пока возвращаем пустой список + return [] + + return [] diff --git a/backend/feed/notifications/signals.py b/backend/feed/notifications/signals.py new file mode 100644 index 00000000..2c485103 --- /dev/null +++ b/backend/feed/notifications/signals.py @@ -0,0 +1,44 @@ +""" +Django signals для автоматической генерации уведомлений в модуле Feed. + +Обрабатывает события: +- post_save для Post - создание новой публикации +- post_save для Comment - добавление комментария +""" + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from ..models import Post, Comment +from .handlers import notify_new_post, notify_comment + + +@receiver(post_save, sender=Post) +def create_post_notification(sender, instance, created, **kwargs): + """ + Создает уведомления при создании новой публикации. + + Уведомления отправляются в зависимости от типа: + - TYPE_COMPANY - всем сотрудникам + - TYPE_DEPARTMENT - сотрудникам отдела + - TYPE_EMPLOYEE - подписчикам автора (если реализовано) + """ + if not created: + return + + # Отправляем уведомления через универсальную систему + # channels.py автоматически обработает через Celery + notify_new_post(instance) + + +@receiver(post_save, sender=Comment) +def create_comment_notification(sender, instance, created, **kwargs): + """ + Создает уведомления при добавлении комментария к публикации. + + Уведомляет автора публикации о новом комментарии. + """ + if not created: + return + + notify_comment(instance)