Skip to content

Commit

Permalink
Send email notifications on mention
Browse files Browse the repository at this point in the history
  • Loading branch information
mtomilov committed Feb 12, 2025
1 parent 32283f4 commit 4c64d50
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 12 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")
9 changes: 8 additions & 1 deletion h/emails/mention_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from h import links
from h.emails.util import get_user_url
from h.notification.mention import MentionNotification
from h.services.email import EmailTag


def generate(request: Request, notification: MentionNotification):
Expand All @@ -27,4 +28,10 @@ def generate(request: Request, notification: MentionNotification):
"h:templates/emails/mention_notification.html.jinja2", context, request=request
)

return [notification.mentioned_user.email], subject, text, html
return (
[notification.mentioned_user.email],
subject,
text,
EmailTag.MENTION_NOTIFICATION,
html,
)
47 changes: 47 additions & 0 deletions h/notification/mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,50 @@ class MentionNotification:
mentioned_user: User
annotation: Annotation
document: Document


def get_notifications(
request, annotation: Annotation, action
) -> list[MentionNotification]:
# 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 mentioned 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

# If the mentioning user mentions self, we don't want to send an email.
if mentioned_user == mentioning_user:
continue

# If the annotation doesn't have a document, we can't send an email.
if annotation.document is None:
continue

notifications.append(
MentionNotification(
mentioning_user, mentioned_user, annotation, annotation.document
)
)

return notifications
1 change: 1 addition & 0 deletions h/services/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class EmailTag(StrEnum):
FLAG_NOTIFICATION = "flag_notification"
REPLY_NOTIFICATION = "reply_notification"
RESET_PASSWORD = "reset_password" # noqa: S105
MENTION_NOTIFICATION = "mention_notification"
TEST = "test"


Expand Down
4 changes: 3 additions & 1 deletion h/services/mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def update_mentions(self, annotation: Annotation) -> None:
if mentioning_user.nipsa:
return

mentioned_userids = OrderedDict.fromkeys(self._parse_userids(annotation.text))
mentioned_userids = OrderedDict.fromkeys(
self._parse_userids(annotation.text)
).keys()
mentioned_users = self._user_service.fetch_all(mentioned_userids)
self._session.execute(
delete(Mention).where(Mention.annotation_id == annotation.id)
Expand Down
26 changes: 25 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,27 @@ 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)
8 changes: 4 additions & 4 deletions tests/unit/h/emails/mention_notification_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ def test_returns_text_and_body_results_from_renderers(
html_renderer.string_response = "HTML output"
text_renderer.string_response = "Text output"

_, _, text, html = generate(pyramid_request, notification)
_, _, text, _, html = generate(pyramid_request, notification)

assert html == "HTML output"
assert text == "Text output"

def test_returns_subject_with_reply_display_name(
self, notification, pyramid_request, mentioning_user
):
_, subject, _, _ = generate(pyramid_request, notification)
_, subject, _, _, _ = generate(pyramid_request, notification)

assert (
subject
Expand All @@ -100,7 +100,7 @@ def test_returns_subject_with_reply_username(
):
mentioning_user.display_name = None

_, subject, _, _ = generate(pyramid_request, notification)
_, subject, _, _, _ = generate(pyramid_request, notification)

assert (
subject == f"{mentioning_user.username} has mentioned you in an annotation"
Expand All @@ -109,7 +109,7 @@ def test_returns_subject_with_reply_username(
def test_returns_parent_email_as_recipients(
self, notification, pyramid_request, mentioned_user
):
recipients, _, _, _ = generate(pyramid_request, notification)
recipients, _, _, _, _ = generate(pyramid_request, notification)

assert recipients == [mentioned_user.email]

Expand Down
85 changes: 85 additions & 0 deletions tests/unit/h/notification/mention_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from unittest.mock import call

import pytest

from h.notification.mention import MentionNotification, 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], MentionNotification)
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_annotation_document_is_empty(
self, pyramid_request, annotation
):
annotation.document = None

assert get_notifications(pyramid_request, annotation, "create") == []

def test_it_returns_empty_list_when_self_mention(
self, pyramid_request, annotation, user_service, mentioning_user
):
user_service.fetch.side_effect = (mentioning_user, mentioning_user)

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
10 changes: 6 additions & 4 deletions tests/unit/h/services/mention_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ def annotation(self, annotation_slim, mentioned_user):
return annotation

@pytest.fixture
def annotation_slim(self, factories):
slim = factories.AnnotationSlim()
slim.user.nipsa = False
return slim
def annotation_slim(self, factories, mentioning_user):
return factories.AnnotationSlim(user=mentioning_user)

@pytest.fixture
def mentioning_user(self, factories):
return factories.User(nipsa=False)

@pytest.fixture
def mentioned_user(self, factories):
Expand Down
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 4c64d50

Please sign in to comment.