From 0ac2fec40151bbc01b2cce70eabf91d0bd8d3e9f Mon Sep 17 00:00:00 2001 From: Wes Appler <145372368+wes-otf@users.noreply.github.com> Date: Thu, 13 Jun 2024 10:18:06 -0400 Subject: [PATCH] Email notifications on new comments for partners (#3913) Fixes #3871. Allows partners that are assigned to an application to also get email notifications when a new comment is made that has applicable visibility. There was some refactoring that took place to allow for email messages to be customized on a per-recipient basis rather than having one be generated for all recipients. --- hypha/apply/activity/adapters/base.py | 10 +- hypha/apply/activity/adapters/emails.py | 43 +++++++- hypha/apply/activity/models.py | 2 +- .../templates/messages/email/comment.html | 2 + .../email/partners_update_partner.html | 2 +- .../activity/templatetags/activity_tags.py | 18 --- hypha/apply/activity/tests/test_messaging.py | 103 +++++++++++++++++- 7 files changed, 149 insertions(+), 31 deletions(-) diff --git a/hypha/apply/activity/adapters/base.py b/hypha/apply/activity/adapters/base.py index ef76c1403..fd729025f 100644 --- a/hypha/apply/activity/adapters/base.py +++ b/hypha/apply/activity/adapters/base.py @@ -172,11 +172,13 @@ class AdapterBase: kwargs.update(self.get_neat_related(message_type, related)) kwargs.update(self.extra_kwargs(message_type, **kwargs)) - message = self.message(message_type, **kwargs) - if not message: - return - for recipient in recipients: + # Allow for customization of message based on recipient string (will vary based on adapter) + message_kwargs = {**kwargs, "recipient": recipient} + message = self.message(message_type, **message_kwargs) + if not message: + continue + message_logs = self.create_logs(message, recipient, *events) if settings.SEND_MESSAGES or self.always_send: diff --git a/hypha/apply/activity/adapters/emails.py b/hypha/apply/activity/adapters/emails.py index 30e4c847a..6ef0c127a 100644 --- a/hypha/apply/activity/adapters/emails.py +++ b/hypha/apply/activity/adapters/emails.py @@ -1,11 +1,14 @@ import logging from collections import defaultdict +from typing import List +from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.template.loader import render_to_string from django.utils.translation import gettext as _ +from hypha.apply.activity.models import ALL, APPLICANT_PARTNERS, PARTNER from hypha.apply.projects.models.payment import CHANGES_REQUESTED_BY_STAFF, DECLINED from hypha.apply.projects.templatetags.project_tags import display_project_status from hypha.apply.users.groups import ( @@ -264,8 +267,11 @@ class EmailAdapter(AdapterBase): def notify_comment(self, **kwargs): comment = kwargs["comment"] - source = kwargs["source"] - if not comment.priviledged and not comment.user == source.user: + recipient = kwargs["recipient"] + # Pass the user object to render_message rather than the email string + recipient_obj = User.objects.get(email__exact=recipient) + kwargs["recipient"] = recipient_obj + if not comment.priviledged: return self.render_message("messages/email/comment.html", **kwargs) def recipients(self, message_type, source, user, **kwargs): @@ -410,6 +416,32 @@ class EmailAdapter(AdapterBase): if isinstance(source, get_user_model()): return user.email + + ApplicationSubmission = apps.get_model("funds", "ApplicationSubmission") + Project = apps.get_model("application_projects", "Project") + if message_type == MESSAGES.COMMENT: + # Comment handling for Submissions + if isinstance(source, ApplicationSubmission): + recipients: List[str] = [source.user.email] + + comment = kwargs["related"] + if partners := list(source.partners.values_list("email", flat=True)): + if comment.visibility == PARTNER: + recipients = partners + elif comment.visibility in [APPLICANT_PARTNERS, ALL]: + recipients += partners + + try: + recipients.remove(comment.user.email) + except ValueError: + pass + + return recipients + + # Comment handling for Projects + if isinstance(source, Project) and user == source.user: + return [] + return [source.user.email] def batch_recipients(self, message_type, sources, **kwargs): @@ -448,7 +480,12 @@ class EmailAdapter(AdapterBase): ) def partners_updated_partner(self, added, removed, **kwargs): - for _partner in added: + if added: + recipient = kwargs["recipient"] + # Pass the user object to render_message rather than the email string + recipient_obj = User.objects.get(email__exact=recipient) + kwargs["recipient"] = recipient_obj + return self.render_message( "messages/email/partners_update_partner.html", **kwargs ) diff --git a/hypha/apply/activity/models.py b/hypha/apply/activity/models.py index d91371ae6..06f8b9eda 100644 --- a/hypha/apply/activity/models.py +++ b/hypha/apply/activity/models.py @@ -243,7 +243,7 @@ class Activity(models.Model): @property def priviledged(self): # Not visible to applicant - return self.visibility not in [APPLICANT, ALL] + return self.visibility not in [APPLICANT, PARTNER, APPLICANT_PARTNERS, ALL] @property def private(self): diff --git a/hypha/apply/activity/templates/messages/email/comment.html b/hypha/apply/activity/templates/messages/email/comment.html index 0990cd8fc..6f4f2dc76 100644 --- a/hypha/apply/activity/templates/messages/email/comment.html +++ b/hypha/apply/activity/templates/messages/email/comment.html @@ -1,5 +1,7 @@ {% extends "messages/email/applicant_base.html" %} + {% load i18n %} +{% block salutation %}{% trans "Dear" %} {{ recipient }},{% endblock %} {% block content %}{# fmt:off #} {% blocktrans with title=source.title user=comment.user %}There has been a new comment on "{{ title }}" by {{ user }}.{% endblocktrans %} diff --git a/hypha/apply/activity/templates/messages/email/partners_update_partner.html b/hypha/apply/activity/templates/messages/email/partners_update_partner.html index 9add70af1..13aa168db 100644 --- a/hypha/apply/activity/templates/messages/email/partners_update_partner.html +++ b/hypha/apply/activity/templates/messages/email/partners_update_partner.html @@ -1,7 +1,7 @@ {% extends "messages/email/base.html" %} {% load i18n %} -{% block salutation %}{% trans "Dear Partner," %}{% endblock %} +{% block salutation %}{% trans "Dear" %} {{ recipient }},{% endblock %} {% block content %}{# fmt:off #} {% trans "You have been added as a partner the following submission." %} diff --git a/hypha/apply/activity/templatetags/activity_tags.py b/hypha/apply/activity/templatetags/activity_tags.py index 7a6fb2754..896a92893 100644 --- a/hypha/apply/activity/templatetags/activity_tags.py +++ b/hypha/apply/activity/templatetags/activity_tags.py @@ -118,21 +118,3 @@ def visibility_display(visibility: str, user) -> str: return f"{visibility} + {team_string}" return visibility - - -@register.filter -def source_type(value) -> str: - """Formats source type - - For a given source type containing "submission", this will be converted - to "Submission" (ie. "application submission" -> "Submission"). - - Args: - value: the source type to be formatted - - Returns: - A source type string with a capitalized first letter - """ - if value and "submission" in value: - return "Submission" - return str(value).capitalize() diff --git a/hypha/apply/activity/tests/test_messaging.py b/hypha/apply/activity/tests/test_messaging.py index e95581f58..12bb122ba 100644 --- a/hypha/apply/activity/tests/test_messaging.py +++ b/hypha/apply/activity/tests/test_messaging.py @@ -18,6 +18,7 @@ from hypha.apply.projects.tests.factories import InvoiceFactory, ProjectFactory from hypha.apply.review.tests.factories import ReviewFactory from hypha.apply.users.tests.factories import ( ApplicantFactory, + PartnerFactory, ReviewerFactory, StaffFactory, UserFactory, @@ -27,7 +28,16 @@ from hypha.apply.utils.testing import make_request from ..adapters import ActivityAdapter, AdapterBase, EmailAdapter, SlackAdapter from ..adapters.base import neat_related from ..messaging import MessengerBackend -from ..models import ALL, TEAM, Activity, Event, Message +from ..models import ( + ALL, + APPLICANT, + APPLICANT_PARTNERS, + PARTNER, + TEAM, + Activity, + Event, + Message, +) from ..options import MESSAGES from .factories import CommentFactory, EventFactory, MessageFactory @@ -496,15 +506,100 @@ class TestEmailAdapter(AdapterMixin, TestCase): self.adapter_process(MESSAGES.COMMENT, related=comment, source=comment.source) self.assertEqual(len(mail.outbox), 0) - def test_no_email_own_comment(self): - application = ApplicationSubmissionFactory() - comment = CommentFactory(user=application.user, source=application) + def test_no_email_own_submission_comment(self): + submission = ApplicationSubmissionFactory() + comment = CommentFactory(user=submission.user, source=submission) self.adapter_process( MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source ) self.assertEqual(len(mail.outbox), 0) + def test_no_email_own_project_comment(self): + project = ProjectFactory() + comment = CommentFactory(user=project.user, source=project) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 0) + + def test_email_staff_submission_comments(self): + staff_commenter = StaffFactory() + submission = ApplicationSubmissionFactory() + comment = CommentFactory( + user=staff_commenter, source=submission, visibility=APPLICANT + ) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 1) + self.assertCountEqual(mail.outbox[0].to, [submission.user.email]) + + def test_email_staff_project_comments(self): + staff_commenter = StaffFactory() + project = ProjectFactory() + comment = CommentFactory( + user=staff_commenter, source=project, visibility=APPLICANT + ) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 1) + self.assertCountEqual(mail.outbox[0].to, [project.user.email]) + + def test_email_partner_for_submission_comments(self): + partners = PartnerFactory.create_batch(2) + submission = ApplicationSubmissionFactory() + submission.partners.set(partners) + comment = CommentFactory( + user=submission.user, source=submission, visibility=PARTNER + ) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 2) + partner_emails = [partner.email for partner in partners] + outbox_emails = [email.to[0] for email in mail.outbox] + self.assertCountEqual(partner_emails, outbox_emails) + + def test_email_applicant_partners_for_submission_comments(self): + staff_commenter = StaffFactory() + partners = PartnerFactory.create_batch(2) + submission = ApplicationSubmissionFactory() + submission.partners.set(partners) + comment = CommentFactory( + user=staff_commenter, source=submission, visibility=APPLICANT_PARTNERS + ) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 3) + applicant_partner_emails = [partner.email for partner in partners] + [ + submission.user.email + ] + outbox_emails = [email.to[0] for email in mail.outbox] + self.assertCountEqual(applicant_partner_emails, outbox_emails) + + def test_email_applicant_for_submission_comments(self): + staff_commenter = StaffFactory() + partners = PartnerFactory.create_batch(2) + submission = ApplicationSubmissionFactory() + submission.partners.set(partners) + comment = CommentFactory( + user=staff_commenter, source=submission, visibility=APPLICANT + ) + + self.adapter_process( + MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source + ) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(submission.user.email, mail.outbox[0].to[0]) + def test_reviewers_email(self): reviewers = ReviewerFactory.create_batch(4) submission = ApplicationSubmissionFactory( -- GitLab