Skip to content

Commit

Permalink
Send email notification on mention
Browse files Browse the repository at this point in the history
  • Loading branch information
mtomilov committed Feb 11, 2025
1 parent c4c4771 commit 2d4d030
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 2 deletions.
2 changes: 1 addition & 1 deletion h/emails/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
45 changes: 45 additions & 0 deletions h/notification/mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 26 additions & 1 deletion h/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
86 changes: 86 additions & 0 deletions tests/unit/h/notification/mention_test.py
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions tests/unit/h/subscribers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down

0 comments on commit 2d4d030

Please sign in to comment.