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