Repository: e-valuation/EvaP

Issue:
Send editor review notice to responsible with others in CC
When sending an editor review notice email to all editors of an evaluation, this message should be addressed to the responsible(s) and CC'ed to all other editors.
Currently the message is sent to each editor individually.


Discussion:
In theory there could be problems with sending too many emails when notifications are split up per evaluation if these have different editors but in practice this won't happen because evaluations don't have editors besides from the responsible after import and this situation will only occur for manually edited evaluations which shouldn't be a lot.

Resolving patch:
diff --git a/evap/contributor/views.py b/evap/contributor/views.py
--- a/evap/contributor/views.py
+++ b/evap/contributor/views.py
@@ -221,7 +221,7 @@ def evaluation_direct_delegation(request, evaluation_id):
 
     # we don't provide the request here since send_to_user only uses it to display a warning message in case the user does not have
     # an email address. In this special case, we don't want that warning. Instead, we want a mail to the admins.
-    template.send_to_user(delegate_user, subject_params, body_params, use_cc=True, additional_cc_user=request.user)
+    template.send_to_user(delegate_user, subject_params, body_params, use_cc=True, additional_cc_users=[request.user])
 
     messages.add_message(
         request,
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -1503,7 +1503,7 @@ def send_to_users_in_evaluations(self, evaluations, recipient_groups, use_cc, re
             body_params = {'user': user, 'evaluations': user_evaluations, 'due_evaluations': user.get_sorted_due_evaluations()}
             self.send_to_user(user, subject_params, body_params, use_cc=use_cc, request=request)
 
-    def send_to_user(self, user, subject_params, body_params, use_cc, additional_cc_user=None, request=None):
+    def send_to_user(self, user, subject_params, body_params, use_cc, additional_cc_users=(), request=None):
         if not user.email:
             warning_message = "{} has no email address defined. Could not send email.".format(user.username)
             # If this method is triggered by a cronjob changing evaluation states, the request is None.
@@ -1516,16 +1516,11 @@ def send_to_user(self, user, subject_params, body_params, use_cc, additional_cc_
                 logger.error(warning_message)
             return
 
-        cc_users = set()
-
-        if additional_cc_user:
-            cc_users.add(additional_cc_user)
+        cc_users = set(additional_cc_users)
 
         if use_cc:
-            cc_users |= set(user.delegates.all() | user.cc_users.all())
-
-            if additional_cc_user:
-                cc_users |= set(additional_cc_user.delegates.all() | additional_cc_user.cc_users.all())
+            users = {user, *additional_cc_users}
+            cc_users |= set(UserProfile.objects.filter(Q(represented_users__in=users) | Q(ccing_users__in=users)))
 
         cc_addresses = [p.email for p in cc_users if p.email]
 
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -220,7 +220,18 @@ def apply(request, evaluations, email_template=None, email_template_contributor=
         messages.success(request, ungettext("Successfully enabled {} evaluation for editor review.",
             "Successfully enabled {} evaluations for editor review.", len(evaluations)).format(len(evaluations)))
         if email_template:
-            email_template.send_to_users_in_evaluations(evaluations, [EmailTemplate.EDITORS], use_cc=True, request=request)
+            evaluations_by_responsible = {}
+            for evaluation in evaluations:
+                for responsible in evaluation.course.responsibles.all():
+                    evaluations_by_responsible.setdefault(responsible, []).append(evaluation)
+
+            for responsible, responsible_evaluations in evaluations_by_responsible.items():
+                body_params = {'user': responsible, 'evaluations': responsible_evaluations}
+                editors = UserProfile.objects \
+                    .filter(contributions__evaluation__in=responsible_evaluations, contributions__can_edit=True) \
+                    .exclude(pk=responsible.pk)
+                email_template.send_to_user(responsible, subject_params={}, body_params=body_params,
+                                            use_cc=True, additional_cc_users=editors, request=request)
 
 
 class StartEvaluationOperation(EvaluationOperation):


Test patch:
diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py
--- a/evap/evaluation/tests/test_models.py
+++ b/evap/evaluation/tests/test_models.py
@@ -538,7 +538,14 @@ def test_login_url_when_use_cc_is_false(self):
         self.assertEqual(mail.outbox[0].body, self.user.login_url)  # message does contain the login url
 
 
+@override_settings(INSTITUTION_EMAIL_DOMAINS=["example.com"])
 class TestEmailTemplate(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = baker.make(UserProfile, email='user@example.com')
+        cls.additional_cc = baker.make(UserProfile, email='additional@example.com')
+        cls.template = EmailTemplate.objects.get(name=EmailTemplate.EDITOR_REVIEW_NOTICE)
+
     @staticmethod
     def test_missing_email_address():
         """
@@ -549,6 +556,68 @@ def test_missing_email_address():
         template = EmailTemplate.objects.get(name=EmailTemplate.STUDENT_REMINDER)
         template.send_to_user(user, {}, {}, False, None)
 
+    def test_put_delegates_in_cc(self):
+        delegate_a = baker.make(UserProfile, email='delegate-a@example.com')
+        delegate_b = baker.make(UserProfile, email='delegate-b@example.com')
+        self.user.delegates.add(delegate_a, delegate_b)
+        self.template.send_to_user(self.user, {}, {}, use_cc=True)
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(set(mail.outbox[0].cc), {delegate_a.email, delegate_b.email})
+
+    def test_put_cc_users_in_cc(self):
+        cc_a = baker.make(UserProfile, email='cc-a@example.com')
+        cc_b = baker.make(UserProfile, email='cc-b@example.com')
+        self.user.cc_users.add(cc_a, cc_b)
+        self.template.send_to_user(self.user, {}, {}, use_cc=True)
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(set(mail.outbox[0].cc), {cc_a.email, cc_b.email})
+
+    def test_put_additional_cc_users_in_cc(self):
+        additional_cc_b = baker.make(UserProfile, email='additional-b@example.com')
+        self.template.send_to_user(self.user, {}, {}, use_cc=True,
+                                   additional_cc_users=[self.additional_cc, additional_cc_b])
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(set(mail.outbox[0].cc), {self.additional_cc.email, additional_cc_b.email})
+
+    def test_put_delegates_of_additional_cc_user_in_cc(self):
+        additional_delegate_a = baker.make(UserProfile, email='additional-delegate-a@example.com')
+        additional_delegate_b = baker.make(UserProfile, email='additional-delegate-b@example.com')
+        self.additional_cc.delegates.add(additional_delegate_a, additional_delegate_b)
+        self.template.send_to_user(self.user, {}, {}, use_cc=True, additional_cc_users=[self.additional_cc])
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(set(mail.outbox[0].cc),
+            {self.additional_cc.email, additional_delegate_a.email, additional_delegate_b.email})
+
+    def test_cc_does_not_contain_duplicates(self):
+        user_a = baker.make(UserProfile, email='a@example.com')
+        user_b = baker.make(UserProfile, email='b@example.com')
+        user_c = baker.make(UserProfile, email='c@example.com')
+        self.user.delegates.add(user_a)
+        self.user.cc_users.add(self.additional_cc, user_b)
+        self.additional_cc.delegates.add(user_b, user_c)
+        self.additional_cc.cc_users.add(user_a, user_c)
+        self.template.send_to_user(self.user, {}, {}, use_cc=True, additional_cc_users=[self.additional_cc])
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(len(mail.outbox[0].cc), 4)
+        self.assertEqual(set(mail.outbox[0].cc), {self.additional_cc.email, user_a.email, user_b.email, user_c.email})
+
+    def test_disable_cc(self):
+        delegate = baker.make(UserProfile, email='delegate@example.com')
+        cc_user = baker.make(UserProfile, email='cc@example.com')
+        self.user.delegates.add(delegate)
+        self.user.cc_users.add(cc_user)
+        self.additional_cc.delegates.add(delegate)
+        self.additional_cc.cc_users.add(cc_user)
+        self.template.send_to_user(self.user, {}, {}, use_cc=False, additional_cc_users=[self.additional_cc])
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(set(mail.outbox[0].cc), {self.additional_cc.email})
+
 
 class TestEmailRecipientList(TestCase):
     def test_recipient_list(self):
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -860,6 +860,8 @@ class TestEvaluationOperationView(WebTest):
     def setUpTestData(cls):
         baker.make(UserProfile, username='manager', groups=[Group.objects.get(name='Manager')])
         cls.semester = baker.make(Semester, pk=1)
+        cls.responsible = baker.make(UserProfile, email='responsible@example.com')
+        cls.course = baker.make(Course, semester=cls.semester, responsibles=[cls.responsible])
 
     def helper_publish_evaluation_with_publish_notifications_for(self, evaluation, contributors=True, participants=True):
         page = self.app.get("/staff/semester/1", user="manager")
@@ -881,8 +883,7 @@ def test_publish_notifications(self):
         participant2 = baker.make(UserProfile, email="bar@example.com")
         contributor1 = baker.make(UserProfile, email="contributor@example.com")
 
-        course = baker.make(Course, semester=self.semester)
-        evaluation = baker.make(Evaluation, course=course, state='reviewed',
+        evaluation = baker.make(Evaluation, course=self.course, state='reviewed',
                                 participants=[participant1, participant2], voters=[participant1, participant2])
         baker.make(Contribution, contributor=contributor1, evaluation=evaluation)
 
@@ -926,35 +927,34 @@ def helper_semester_state_views(self, evaluation, old_state, new_state):
     def test_semester_publish(self):
         participant1 = baker.make(UserProfile, email="foo@example.com")
         participant2 = baker.make(UserProfile, email="bar@example.com")
-        course = baker.make(Course, semester=self.semester)
-        evaluation = baker.make(Evaluation, course=course, state='reviewed',
+        evaluation = baker.make(Evaluation, course=self.course, state='reviewed',
                                 participants=[participant1, participant2], voters=[participant1, participant2])
 
         self.helper_semester_state_views(evaluation, "reviewed", "published")
         self.assertEqual(len(mail.outbox), 2)
 
     def test_semester_reset_1(self):
-        evaluation = baker.make(Evaluation, course=baker.make(Course, semester=self.semester), state='prepared')
+        evaluation = baker.make(Evaluation, course=self.course, state='prepared')
         self.helper_semester_state_views(evaluation, "prepared", "new")
 
     def test_semester_reset_2(self):
-        evaluation = baker.make(Evaluation, course=baker.make(Course, semester=self.semester), state='approved')
+        evaluation = baker.make(Evaluation, course=self.course, state='approved')
         self.helper_semester_state_views(evaluation, "approved", "new")
 
     def test_semester_contributor_ready_1(self):
-        evaluation = baker.make(Evaluation, course=baker.make(Course, semester=self.semester), state='new')
+        evaluation = baker.make(Evaluation, course=self.course, state='new')
         self.helper_semester_state_views(evaluation, "new", "prepared")
 
     def test_semester_contributor_ready_2(self):
-        evaluation = baker.make(Evaluation, course=baker.make(Course, semester=self.semester), state='editor_approved')
+        evaluation = baker.make(Evaluation, course=self.course, state='editor_approved')
         self.helper_semester_state_views(evaluation, "editor_approved", "prepared")
 
     def test_semester_unpublish(self):
-        evaluation = baker.make(Evaluation, course=baker.make(Course, semester=self.semester), state='published', _participant_count=0, _voter_count=0)
+        evaluation = baker.make(Evaluation, course=self.course, state='published', _participant_count=0, _voter_count=0)
         self.helper_semester_state_views(evaluation, "published", "reviewed")
 
     def test_operation_start_evaluation(self):
-        evaluation = baker.make(Evaluation, state='approved', course=baker.make(Course, semester=self.semester))
+        evaluation = baker.make(Evaluation, state='approved', course=self.course)
         urloptions = '?evaluation={}&target_state=in_evaluation'.format(evaluation.pk)
 
         response = self.app.get(self.url + urloptions, user='manager')
@@ -967,18 +967,119 @@ def test_operation_start_evaluation(self):
         self.assertEqual(evaluation.state, 'in_evaluation')
 
     def test_operation_prepare(self):
-        evaluation = baker.make(Evaluation, state='new', course=baker.make(Course, semester=self.semester))
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
         urloptions = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
 
         response = self.app.get(self.url + urloptions, user='manager')
         self.assertEqual(response.status_code, 200, 'url "{}" failed with user "manager"'.format(self.url))
-
         form = response.forms['evaluation-operation-form']
         form.submit()
 
         evaluation = Evaluation.objects.get(pk=evaluation.pk)
         self.assertEqual(evaluation.state, 'prepared')
 
+    def submit_operation_prepare_form(self, url_options):
+        actual_emails = []
+
+        def mock(email_template, user, subject_params, body_params, use_cc, additional_cc_users=None, request=None):
+            actual_emails.append({
+                'user': user,
+                'subject': email_template.subject,
+                'subject_params': subject_params,
+                'body': email_template.body,
+                'body_params': body_params,
+                'use_cc': use_cc,
+                'additional_cc_users': set(additional_cc_users),
+            })
+
+        response = self.app.get(self.url + url_options, user='manager')
+        form = response.forms['evaluation-operation-form']
+        form['send_email'] = True
+        form['email_subject'] = 'New evaluations ready for review'
+        form['email_body'] = 'There are evaluations that need your approval.'
+
+        with patch.object(EmailTemplate, 'send_to_user', mock):
+            form.submit()
+
+        return actual_emails
+
+    def test_operation_prepare_sends_email_to_responsible(self):
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
+        url_options = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(actual_emails, [{
+            'user': self.responsible,
+            'subject': 'New evaluations ready for review',
+            'subject_params': {},
+            'body': 'There are evaluations that need your approval.',
+            'body_params': {'user': self.responsible, 'evaluations': [evaluation]},
+            'use_cc': True,
+            'additional_cc_users': set(),
+        }])
+
+    def test_operation_prepare_sends_one_email_to_each_responsible(self):
+        other_responsible = baker.make(UserProfile, email='co-responsible@example.com')
+        self.course.responsibles.add(other_responsible)
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
+        url_options = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(len(actual_emails), 2)
+
+        email_to_responsible = next(email for email in actual_emails if email['user'] == self.responsible)
+        self.assertEqual(email_to_responsible['body_params'], {'user': self.responsible, 'evaluations': [evaluation]})
+
+        email_to_other_responsible = next(email for email in actual_emails if email['user'] == other_responsible)
+        self.assertEqual(email_to_other_responsible['body_params'], {'user': other_responsible, 'evaluations': [evaluation]})
+
+    def test_operation_prepare_with_multiple_evaluations(self):
+        responsible_b = baker.make(UserProfile, email='responsible-b@example.com')
+        course_b = baker.make(Course, semester=self.semester, responsibles=[responsible_b])
+        evaluation_a = baker.make(Evaluation, state='new', course=self.course)
+        evaluation_b = baker.make(Evaluation, state='new', course=course_b)
+        url_options = '?evaluation={}&evaluation={}&target_state=prepared'.format(evaluation_a.pk, evaluation_b.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(len(actual_emails), 2)
+
+        email_to_responsible = next(email for email in actual_emails if email['user'] == self.responsible)
+        self.assertEqual(email_to_responsible['body_params'], {'user': self.responsible, 'evaluations': [evaluation_a]})
+
+        email_to_responsible_b = next(email for email in actual_emails if email['user'] == responsible_b)
+        self.assertEqual(email_to_responsible_b['body_params'], {'user': responsible_b, 'evaluations': [evaluation_b]})
+
+    def test_operation_prepare_sends_email_with_editors_in_cc(self):
+        editor_a = baker.make(UserProfile, email='editor-a@example.com')
+        editor_b = baker.make(UserProfile, email='editor-b@example.com')
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
+        baker.make(Contribution, evaluation=evaluation, contributor=editor_a, can_edit=True)
+        baker.make(Contribution, evaluation=evaluation, contributor=editor_b, can_edit=True)
+        url_options = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(len(actual_emails), 1)
+        self.assertEqual(actual_emails[0]['additional_cc_users'], {editor_a, editor_b})
+
+    def test_operation_prepare_does_not_put_responsible_into_cc(self):
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
+        baker.make(Contribution, evaluation=evaluation, contributor=self.responsible, can_edit=True)
+        url_options = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(len(actual_emails), 1)
+        self.assertEqual(actual_emails[0]['additional_cc_users'], set())
+
+    def test_operation_prepare_does_not_send_email_to_contributors(self):
+        contributor = baker.make(UserProfile, email='contributor@example.com')
+        evaluation = baker.make(Evaluation, state='new', course=self.course)
+        baker.make(Contribution, evaluation=evaluation, contributor=contributor, can_edit=False)
+        url_options = '?evaluation={}&target_state=prepared'.format(evaluation.pk)
+        actual_emails = self.submit_operation_prepare_form(url_options)
+
+        self.assertEqual(len(actual_emails), 1)
+        self.assertEqual(actual_emails[0]['additional_cc_users'], set())
+
 
 class TestCourseCreateView(WebTest):
     url = '/staff/semester/1/course/create'