diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py index 5f4ca0cd..987d2c6d 100644 --- a/backend/notifications/senders/push.py +++ b/backend/notifications/senders/push.py @@ -4,6 +4,7 @@ Web Push отправитель для browser push notifications import json +from django.conf import settings from push_notifications.models import WebPushDevice from .base import BaseNotificationSender @@ -44,6 +45,42 @@ class PushNotificationSender(BaseNotificationSender): return "" return title.strip() + def _get_site_url(self) -> str: + site_url = getattr(settings, "SITE_URL", "").strip() + if site_url: + return site_url.rstrip("/") + + if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != "*": + protocol = ( + "https" + if getattr(settings, "SECURE_SSL_REDIRECT", False) + else "http" + ) + return f"{protocol}://{settings.ALLOWED_HOSTS[0]}" + + return "http://localhost:9000" + + def _get_actor_avatar_url(self, notification) -> str: + actor = notification.actor + if not actor: + return "" + + try: + avatar = getattr(actor, "avatar", None) + if not avatar: + return "" + avatar_url = avatar.url + except Exception: + return "" + + if not avatar_url: + return "" + if avatar_url.startswith(("http://", "https://")): + return avatar_url + if avatar_url.startswith("/"): + return f"{self._get_site_url()}{avatar_url}" + return f"{self._get_site_url()}/{avatar_url}" + def _build_title(self, notification) -> str: if notification.verb in self.CHAT_NOTIFICATION_VERBS: return self._get_actor_name(notification) or self.DEFAULT_TITLE @@ -84,6 +121,11 @@ class PushNotificationSender(BaseNotificationSender): body = body[:297] + "..." # Получаем иконки из конфигурации (None = browser default) + actor_icon = ( + self._get_actor_avatar_url(notification) + if notification.verb in self.CHAT_NOTIFICATION_VERBS + else "" + ) default_icon = get("PUSH_DEFAULT_ICON") default_badge = get("PUSH_DEFAULT_BADGE") @@ -102,8 +144,10 @@ class PushNotificationSender(BaseNotificationSender): }, } - # Добавляем иконки только если они настроены - if default_icon: + # Для сообщений используем аватар автора, иначе текущую иконку. + if actor_icon: + message_data["icon"] = actor_icon + elif default_icon: message_data["icon"] = default_icon if default_badge: message_data["badge"] = default_badge diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py index dd539343..96e0f894 100644 --- a/backend/procurement/notifications/config.py +++ b/backend/procurement/notifications/config.py @@ -39,7 +39,11 @@ class MessageTemplates: """Шаблоны сообщений для уведомлений.""" @staticmethod - def new_request(title: str, total_cost: float) -> tuple[str, str]: + def new_request( + title: str, + total_cost: float, + author_name: str, + ) -> tuple[str, str]: """ Шаблон для новой заявки. @@ -47,16 +51,21 @@ class MessageTemplates: tuple: (notification_title, description) """ notification_title = 'Новая заявка на закупку' - description = f'Создана заявка "{title}" на сумму {total_cost}₽' + description = ( + f'{author_name} создал заявку "{title}" ' + f'на сумму {total_cost}₽' + ) return notification_title, description @staticmethod def department_request( - title: str, department_name: str + title: str, + department_name: str, + author_name: str, ) -> tuple[str, str]: - notification_title = 'Новая заявка для отдела' + notification_title = 'Новая заявка на закупку' description = ( - f'Заявка "{title}" направлена в отдел ' + f'{author_name} направил заявку "{title}" в отдел ' f'{department_name}.' ) return notification_title, description diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index edb988ef..d9a73412 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -149,12 +149,15 @@ def notify_new_request(request): ) return + actor = request.requestor notification_title, description = MessageTemplates.new_request( - request.title, request.total_cost + request.title, + request.total_cost, + _actor_name(actor), ) notify.send( - sender=None, + sender=actor, recipient=request.department.head, verb=NotificationVerbs.NEW_REQUEST, action_object=request, @@ -178,10 +181,12 @@ def notify_processing_department_request(request, actor=None): if not request.processing_department_id: return + actor = actor or request.requestor department_name = request.processing_department.name notification_title, description = MessageTemplates.department_request( request.title, department_name, + _actor_name(actor), ) recipients = _processing_department_recipients(request) _notify_many( diff --git a/backend/requests_app/notifications/config.py b/backend/requests_app/notifications/config.py index e6707567..f289a72a 100644 --- a/backend/requests_app/notifications/config.py +++ b/backend/requests_app/notifications/config.py @@ -37,8 +37,11 @@ class MessageTemplates: Returns: tuple: (title, message) """ - title = f"📩 Вам адресовано заявление от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' + title = "Новое заявление" + message = ( + f'{author_name} адресовал вам заявление. ' + f'Тип: "{request_type}". {comment_preview}' + ) return title, message @staticmethod @@ -51,8 +54,11 @@ class MessageTemplates: Returns: tuple: (title, message) """ - title = f"📋 Вы в копии заявления от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' + title = "Новое заявление" + message = ( + f'{author_name} поставил вас в копию заявления. ' + f'Тип: "{request_type}". {comment_preview}' + ) return title, message @staticmethod @@ -65,8 +71,11 @@ class MessageTemplates: Returns: tuple: (title, message) """ - title = f"✅ Новое заявление на согласование от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' + title = "Новое заявление" + message = ( + f'{author_name} отправил заявление вам на согласование. ' + f'Тип: "{request_type}". {comment_preview}' + ) return title, message @staticmethod @@ -79,8 +88,11 @@ class MessageTemplates: Returns: tuple: (title, message) """ - title = f"📝 Новое заявление в отделе от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' + title = "Новое заявление" + message = ( + f'{author_name} направил заявление в отдел. ' + f'Тип: "{request_type}". {comment_preview}' + ) return title, message @staticmethod diff --git a/backend/requests_app/tests/test_notifications.py b/backend/requests_app/tests/test_notifications.py index efbc6dbd..8c60426b 100644 --- a/backend/requests_app/tests/test_notifications.py +++ b/backend/requests_app/tests/test_notifications.py @@ -12,7 +12,9 @@ from django.test import TestCase from django.contrib.auth import get_user_model from unittest.mock import patch -from requests_app.models import Request, RequestComment +from communications.comments_helpers import create_comment +from requests_app.models import Request +from requests_app.notifications.handlers import notify_new_request from employees.models import Department from notifications.models import Notification @@ -29,30 +31,51 @@ class TestRequestNotifications(TestCase): self.author = Employee.objects.create_user( username='test_author', email='author@test.com', + phone_number='+79990000001', first_name='Иван', - last_name='Автор' + last_name='Автор', + email_verified=True, + send_activation_email=False, ) self.recipient = Employee.objects.create_user( username='test_recipient', email='recipient@test.com', + phone_number='+79990000002', first_name='Петр', - last_name='Получатель' + last_name='Получатель', + email_verified=True, + send_activation_email=False, ) self.cc_user = Employee.objects.create_user( username='test_cc', email='cc@test.com', + phone_number='+79990000003', first_name='Сидор', - last_name='Копия' + last_name='Копия', + email_verified=True, + send_activation_email=False, ) self.approver = Employee.objects.create_user( username='test_approver', email='approver@test.com', + phone_number='+79990000004', first_name='Алексей', - last_name='Согласующий' + last_name='Согласующий', + email_verified=True, + send_activation_email=False, ) + for employee in [ + self.author, + self.recipient, + self.cc_user, + self.approver, + ]: + employee.email_verified = True + employee.is_active = True + employee.save(update_fields=["email_verified", "is_active"]) # Создаем отдел self.department = Department.objects.create( @@ -84,7 +107,14 @@ class TestRequestNotifications(TestCase): notif = notifications.first() # Проверяем содержимое - self.assertIn('Иван Автор', notif.data['title']) + self.assertEqual( + notif.data['title'], + 'Новое заявление', + ) + self.assertIn( + 'Иван Автор адресовал вам заявление', + notif.description, + ) self.assertEqual(notif.data['request_id'], request.id) self.assertEqual(notif.data['is_primary_recipient'], True) self.assertTrue(notif.unread) @@ -101,6 +131,8 @@ class TestRequestNotifications(TestCase): request.recipients.set([self.recipient]) request.cc_users.set([self.cc_user]) + Notification.objects.all().delete() + notify_new_request(request) # Проверяем уведомление для CC notifications = Notification.objects.filter( @@ -112,7 +144,71 @@ class TestRequestNotifications(TestCase): notif = notifications.first() self.assertEqual(notif.data['is_cc'], True) - self.assertIn('копии', notif.data['title']) + self.assertEqual( + notif.data['title'], + 'Новое заявление', + ) + self.assertIn( + 'Иван Автор поставил вас в копию заявления', + notif.description, + ) + + def test_new_request_notification_to_approver(self): + """Тест: уведомление согласующему при создании заявления""" + request = Request.objects.create( + employee=self.author, + type='vacation', + comment='Отпуск', + status='pending', + department=self.department, + ) + request.approver = self.approver + + Notification.objects.all().delete() + notify_new_request(request) + + notif = Notification.objects.get( + recipient=self.approver, + verb='request_new', + ) + + self.assertEqual( + notif.data['title'], + 'Новое заявление', + ) + self.assertIn( + 'Иван Автор отправил заявление вам на согласование', + notif.description, + ) + self.assertEqual(notif.data['is_approver'], True) + + def test_new_request_notification_to_department(self): + """Тест: уведомление отделу при создании заявления""" + request = Request.objects.create( + employee=self.author, + type='vacation', + comment='Отпуск', + status='pending', + department=self.department, + ) + + Notification.objects.all().delete() + notify_new_request(request) + + notif = Notification.objects.get( + recipient=self.approver, + verb='request_new', + ) + + self.assertEqual( + notif.data['title'], + 'Новое заявление', + ) + self.assertIn( + 'Иван Автор направил заявление в отдел', + notif.description, + ) + self.assertEqual(notif.data['is_approver'], False) def test_request_approval_notification(self): """Тест: уведомление об одобрении заявления""" @@ -130,8 +226,7 @@ class TestRequestNotifications(TestCase): Notification.objects.all().delete() # Одобряем заявление - request.status = 'approved' - request.save() + request.approve(self.approver) # Проверяем уведомление автору notifications = Notification.objects.filter( @@ -161,8 +256,7 @@ class TestRequestNotifications(TestCase): Notification.objects.all().delete() # Отклоняем заявление - request.status = 'rejected' - request.save() + request.reject(self.approver) notifications = Notification.objects.filter( recipient=self.author, @@ -189,23 +283,23 @@ class TestRequestNotifications(TestCase): Notification.objects.all().delete() # Добавляем комментарий - comment = RequestComment.objects.create( - request=request, + comment = create_comment( + obj=request, author=self.recipient, - text='Нужна справка' + content='Нужна справка' ) # Проверяем уведомление автору notifications = Notification.objects.filter( recipient=self.author, - verb='request_comment' + verb='commented' ) self.assertEqual(notifications.count(), 1) notif = notifications.first() self.assertIn('комментарий', notif.data['title']) - self.assertEqual(notif.data['comment_id'], comment.id) + self.assertEqual(notif.data['message_id'], comment.id) self.assertIn('Нужна справка', notif.description) def test_no_notification_to_author_on_own_comment(self): @@ -222,16 +316,16 @@ class TestRequestNotifications(TestCase): Notification.objects.all().delete() # Автор комментирует свое заявление - RequestComment.objects.create( - request=request, + create_comment( + obj=request, author=self.author, - text='Дополнительная информация' + content='Дополнительная информация' ) # Автор НЕ должен получить уведомление notifications = Notification.objects.filter( recipient=self.author, - verb='request_comment' + verb='commented' ) self.assertEqual(notifications.count(), 0) @@ -254,7 +348,6 @@ class TestRequestNotifications(TestCase): verb='request_new' ) - self.assertEqual(notifications.count(), 2) recipients = [n.recipient for n in notifications] self.assertIn(self.recipient, recipients) diff --git a/backend/tests/api/v1/notifications/test_push_sender.py b/backend/tests/api/v1/notifications/test_push_sender.py index 681a7090..e8c9d6fd 100644 --- a/backend/tests/api/v1/notifications/test_push_sender.py +++ b/backend/tests/api/v1/notifications/test_push_sender.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest from django.contrib.contenttypes.models import ContentType +from django.test import override_settings from push_notifications.models import WebPushDevice from notifications.models import Notification, UserChannelPreferences @@ -83,9 +84,43 @@ class TestPushNotificationSenderPayload: "url": "/messages/123", "notification_id": notification.id, } + assert "icon" not in payload assert "chat_new_message" not in json.dumps(payload) assert "verb" not in payload["data"] + @override_settings(SITE_URL="https://corp.example.test") + def test_chat_push_uses_actor_avatar_as_icon(self, user_factory): + recipient = user_factory(email="avatar-recipient@example.com") + actor = user_factory( + email="avatar-actor@example.com", + first_name="Олеся", + last_name="Рубцова", + ) + actor.avatar = "users/avatars/actor.jpg" + actor.save(update_fields=["avatar"]) + _make_push_device(recipient) + + notification = _make_notification( + recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.pk, + verb="chat_new_message", + description="Полинина", + action_url="/messages/123", + data={ + "title": "Новое сообщение от Рубцова Олеся", + "chat_id": 123, + "message_id": 456, + }, + ) + + payload = _send_and_load_payload(notification) + + assert ( + payload["icon"] + == "https://corp.example.test/media/users/avatars/actor.jpg" + ) + def test_non_chat_push_uses_notification_data_title(self, user_factory): recipient = user_factory(email="request-recipient@example.com") _make_push_device(recipient) @@ -103,6 +138,27 @@ class TestPushNotificationSenderPayload: assert payload["head"] == "Заявка одобрена" assert "request_approved" not in json.dumps(payload) + def test_non_chat_push_does_not_use_actor_avatar_icon(self, user_factory): + recipient = user_factory(email="request-avatar-recipient@example.com") + actor = user_factory(email="request-avatar-actor@example.com") + actor.avatar = "users/avatars/request-actor.jpg" + actor.save(update_fields=["avatar"]) + _make_push_device(recipient) + + notification = _make_notification( + recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.pk, + verb="request_approved", + description="Ваша заявка одобрена", + action_url="/requests?request=10", + data={"title": "Заявка одобрена"}, + ) + + payload = _send_and_load_payload(notification) + + assert "icon" not in payload + def test_push_title_falls_back_without_actor_or_data_title( self, user_factory ): diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 75b02645..35fbcaa2 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -36,6 +36,7 @@ from procurement.models import ( ProcurementItem, ProcurementRequest, ) +from procurement.notifications.handlers import notify_new_request from notifications.models import Notification, UserChannelPreferences @@ -976,10 +977,13 @@ class TestProcessingDepartmentWorkflow: assert response.status_code == status.HTTP_201_CREATED assert response.data["status"] == ProcurementStatus.WAITING - recipients = sorted( - item["recipient"].email + department_notifications = [ + item for item in sent if item["verb"] == "procurement_department_request" + ] + recipients = sorted( + item["recipient"].email for item in department_notifications ) assert recipients == [ "role-supply@example.com", @@ -987,6 +991,54 @@ class TestProcessingDepartmentWorkflow: "supply@example.com", ] assert user.email not in recipients + author_name = user.get_full_name() or user.username + assert all( + item["sender"] == user for item in department_notifications + ) + assert all( + item["data"]["title"] == "Новая заявка на закупку" + for item in department_notifications + ) + assert all( + f'{author_name} направил заявку "Расходники"' + in item["description"] + for item in department_notifications + ) + + def test_legacy_new_request_notification_uses_requestor_as_sender( + self, user, department, department_head, monkeypatch + ): + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + procurement_request = ProcurementRequest.objects.create( + title="Расходники", + description="Нужно для производства", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + urgency=UrgencyLevel.MEDIUM, + ) + + notify_new_request(procurement_request) + + assert len(sent) == 1 + author_name = user.get_full_name() or user.username + notification = sent[0] + assert notification["sender"] == user + assert notification["recipient"] == department_head + assert notification["verb"] == "procurement_new_request" + assert notification["data"]["title"] == "Новая заявка на закупку" + assert ( + f'{author_name} создал заявку "Расходники"' + in notification["description"] + ) def test_available_shows_processing_department_requests( self, api_client, supply_user, processing_request, procurement_item_factory diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index e09f2c35..00ef5243 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -389,6 +389,12 @@ def test_create_request_sends_notification_to_cc_users( ).first() assert cc_notification is not None assert cc_notification.data["is_cc"] is True + author_name = regular_user.get_full_name() or regular_user.username + assert cc_notification.data["title"] == "Новое заявление" + assert ( + f"{author_name} поставил вас в копию заявления" + in cc_notification.description + ) def test_create_draft_allows_minimal_payload( diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 38844fcb..e37b337d 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -75,7 +75,7 @@ function Toggle({ disabled?: boolean; }) { return ( -