From 2d4d0300910e07c4862583cfc7bba264e1ae059f Mon Sep 17 00:00:00 2001 From: Misha Tomilov Date: Tue, 11 Feb 2025 09:56:02 +0100 Subject: [PATCH] Send email notification on mention --- h/emails/__init__.py | 2 +- h/notification/mention.py | 45 ++++++++++++ h/subscribers.py | 27 ++++++- tests/unit/h/notification/mention_test.py | 86 +++++++++++++++++++++++ tests/unit/h/subscribers_test.py | 64 +++++++++++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 tests/unit/h/notification/mention_test.py diff --git a/h/emails/__init__.py b/h/emails/__init__.py index ec4d9188ef6..b854872b993 100644 --- a/h/emails/__init__.py +++ b/h/emails/__init__.py @@ -1,3 +1,3 @@ -from h.emails import reply_notification, reset_password, signup +from h.emails import mention_notification, reply_notification, reset_password, signup __all__ = ("mention_notification", "reply_notification", "reset_password", "signup") diff --git a/h/notification/mention.py b/h/notification/mention.py index cc690d4b946..13126b3caa1 100644 --- a/h/notification/mention.py +++ b/h/notification/mention.py @@ -14,3 +14,48 @@ class Notification: mentioned_user: User annotation: Annotation document: Document + + +def get_notifications(request, annotation: Annotation, action) -> list[Notification]: + # Only send notifications when new annotations are created + if action != "create": + return [] + + user_service = request.find_service(name="user") + + # If the mentioning user doesn't exist (anymore), we can't send emails, but + # this would be super weird, so log a warning. + mentioning_user = user_service.fetch(annotation.userid) + if mentioning_user is None: + logger.warning( + "user who just mentioned another user no longer exists: %s", + annotation.userid, + ) + return [] + + notifications = [] + for mention in annotation.mentions: + # If the mentioning user doesn't exist (anymore), we can't send emails + mentioned_user = user_service.fetch(mention.user.userid) + if mentioned_user is None: + continue + + # If mentioned user doesn't have an email address we can't email them. + if not mention.user.email: + continue + + # Do not notify users about their own replies + if mentioning_user == mentioned_user: + continue + + # If the annotation doesn't have a document, we can't send an email. + if annotation.document is None: + continue + + notifications.append( + Notification( + mentioning_user, mentioned_user, annotation, annotation.document + ) + ) + + return notifications diff --git a/h/subscribers.py b/h/subscribers.py index 704d2f306cb..e8e9c078ae6 100644 --- a/h/subscribers.py +++ b/h/subscribers.py @@ -5,7 +5,7 @@ from h import __version__, emails from h.events import AnnotationEvent from h.exceptions import RealtimeMessageQueueError -from h.notification import reply +from h.notification import mention, reply from h.services.annotation_read import AnnotationReadService from h.tasks import mailer @@ -89,3 +89,28 @@ def send_reply_notifications(event): except OperationalError as err: # pragma: no cover # We could not connect to rabbit! So carry on report_exception(err) + + +@subscriber(AnnotationEvent) +def send_mention_notifications(event): + """Send mention notifications triggered by a mention event.""" + + request = event.request + + with request.tm: + annotation = request.find_service(AnnotationReadService).get_annotation_by_id( + event.annotation_id, + ) + notifications = mention.get_notifications(request, annotation, event.action) + + if not notifications: + return + + for notification in notifications: + send_params = emails.mention_notification.generate(request, notification) + + try: + mailer.send.delay(*send_params) + except OperationalError as err: # pragma: no cover + # We could not connect to rabbit! So carry on + report_exception(err) diff --git a/tests/unit/h/notification/mention_test.py b/tests/unit/h/notification/mention_test.py new file mode 100644 index 00000000000..5ee85e42da1 --- /dev/null +++ b/tests/unit/h/notification/mention_test.py @@ -0,0 +1,86 @@ +from unittest.mock import call + +import pytest + +from h.notification.mention import Notification, get_notifications + + +class TestGetNotifications: + def test_it( + self, annotation, mentioning_user, mentioned_user, pyramid_request, user_service + ): + result = get_notifications(pyramid_request, annotation, "create") + + user_service.fetch.assert_has_calls( + [call(mentioning_user.userid), call(mentioned_user.userid)] + ) + + assert len(result) == 1 + assert isinstance(result[0], Notification) + assert result[0].mentioning_user == mentioning_user + assert result[0].mentioned_user == mentioned_user + assert result[0].annotation == annotation + assert result[0].document == annotation.document + + def test_it_returns_empty_list_when_action_is_not_create( + self, pyramid_request, annotation + ): + assert get_notifications(pyramid_request, annotation, "NOT_CREATE") == [] + + def test_it_returns_empty_list_when_mentioning_user_does_not_exist( + self, pyramid_request, annotation, user_service, factories + ): + user_service.fetch.side_effect = (None, factories.User()) + + assert get_notifications(pyramid_request, annotation, "create") == [] + + def test_it_returns_empty_list_when_mentioned_user_does_not_exist( + self, pyramid_request, annotation, user_service, factories + ): + user_service.fetch.side_effect = (factories.User(), None) + + assert get_notifications(pyramid_request, annotation, "create") == [] + + def test_it_returns_empty_list_when_mentioned_user_has_no_email_address( + self, pyramid_request, annotation, mentioned_user + ): + mentioned_user.email = None + assert get_notifications(pyramid_request, annotation, "create") == [] + + def test_it_returns_empty_list_when_mentioning_the_same_user( + self, pyramid_request, annotation, user_service, factories + ): + single_user = factories.User() + user_service.fetch.side_effect = (single_user, single_user) + + assert get_notifications(pyramid_request, annotation, "create") == [] + + def test_it_returns_empty_list_when_annotation_document_is_empty( + self, pyramid_request, annotation + ): + annotation.document = None + + assert get_notifications(pyramid_request, annotation, "create") == [] + + @pytest.fixture + def annotation(self, factories, mentioning_user, mention): + return factories.Annotation( + userid=mentioning_user.userid, shared=True, mentions=[mention] + ) + + @pytest.fixture + def mentioned_user(self, factories): + return factories.User(nipsa=False) + + @pytest.fixture + def mentioning_user(self, factories): + return factories.User(nipsa=False) + + @pytest.fixture + def mention(self, factories, mentioned_user): + return factories.Mention(user=mentioned_user) + + @pytest.fixture(autouse=True) + def user_service(self, user_service, mentioning_user, mentioned_user): + user_service.fetch.side_effect = (mentioning_user, mentioned_user) + return user_service diff --git a/tests/unit/h/subscribers_test.py b/tests/unit/h/subscribers_test.py index a6a95835af3..42c25baf51f 100644 --- a/tests/unit/h/subscribers_test.py +++ b/tests/unit/h/subscribers_test.py @@ -159,6 +159,65 @@ def pyramid_request(self, pyramid_request): return pyramid_request +@pytest.mark.usefixtures("annotation_read_service") +class TestSendMentionNotifications: + def test_it_sends_emails( + self, + event, + pyramid_request, + annotation_read_service, + mention, + emails, + mailer, + ): + notifications = [mock.MagicMock()] + mention.get_notifications.return_value = notifications + + subscribers.send_mention_notifications(event) + + # This is a pure plumbing test, checking everything is connected to + # everything else as we expect + annotation_read_service.get_annotation_by_id.assert_called_once_with( + event.annotation_id + ) + annotation = annotation_read_service.get_annotation_by_id.return_value + mention.get_notifications.assert_called_once_with( + pyramid_request, annotation, event.action + ) + emails.mention_notification.generate.assert_called_once_with( + pyramid_request, notifications[0] + ) + send_params = emails.mention_notification.generate.return_value + mailer.send.delay.assert_called_once_with(*send_params) + + def test_it_does_nothing_if_no_notification_is_required( + self, event, mention, mailer + ): + mention.get_notifications.return_value = [] + + subscribers.send_mention_notifications(event) + + mailer.send.delay.assert_not_called() + + def test_it_fails_gracefully_if_the_task_does_not_queue( + self, event, mailer, mention + ): + mention.get_notifications.return_value = [mock.MagicMock()] + mailer.send.side_effect = OperationalError + + # No explosions please + subscribers.send_mention_notifications(event) + + @pytest.fixture + def event(self, pyramid_request): + return AnnotationEvent(pyramid_request, {"id": "any"}, "action") + + @pytest.fixture + def pyramid_request(self, pyramid_request): + pyramid_request.tm = mock.MagicMock() + return pyramid_request + + class TestSyncAnnotation: def test_it_calls_sync_service( self, pyramid_request, search_index, transaction_manager @@ -184,6 +243,11 @@ def reply(patch): return patch("h.subscribers.reply") +@pytest.fixture(autouse=True) +def mention(patch): + return patch("h.subscribers.mention") + + @pytest.fixture(autouse=True) def mailer(patch): return patch("h.subscribers.mailer")