From 52309d554181a375f30aa57e9eb9a54a0d05702a Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso Date: Mon, 9 Mar 2020 10:16:08 -0400 Subject: [PATCH] AA-38 calendar sync email template - create templates and methods to build/send calendar sync emails. --- .../edx_ace/calendarsync/email/body.html | 22 +++++ .../edx_ace/calendarsync/email/body.txt | 2 + .../edx_ace/calendarsync/email/from_name.txt | 1 + .../edx_ace/calendarsync/email/head.html | 1 + .../edx_ace/calendarsync/email/subject.txt | 4 + lms/envs/common.py | 1 + .../features/calendar_sync/message_types.py | 13 +++ openedx/features/calendar_sync/tasks.py | 71 ++++++++++++++++ .../calendar_sync/tests/test_tasks.py | 83 +++++++++++++++++++ .../calendar_sync/tests/test_views.py | 39 +++++++++ .../calendar_sync/views/management.py | 64 ++++++++++++++ .../course_experience/views/course_dates.py | 2 +- 12 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 common/templates/student/edx_ace/calendarsync/email/body.html create mode 100644 common/templates/student/edx_ace/calendarsync/email/body.txt create mode 100644 common/templates/student/edx_ace/calendarsync/email/from_name.txt create mode 100644 common/templates/student/edx_ace/calendarsync/email/head.html create mode 100644 common/templates/student/edx_ace/calendarsync/email/subject.txt create mode 100644 openedx/features/calendar_sync/message_types.py create mode 100644 openedx/features/calendar_sync/tasks.py create mode 100644 openedx/features/calendar_sync/tests/test_tasks.py create mode 100644 openedx/features/calendar_sync/views/management.py diff --git a/common/templates/student/edx_ace/calendarsync/email/body.html b/common/templates/student/edx_ace/calendarsync/email/body.html new file mode 100644 index 0000000000..9ee009defc --- /dev/null +++ b/common/templates/student/edx_ace/calendarsync/email/body.html @@ -0,0 +1,22 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load django_markup %} +{% load i18n %} +{% load static %} +{% block content %} + + + + +
+

+ {% filter force_escape %}{{ calendar_sync_headline }}{% endfilter %} +

+

+ {% filter force_escape %} + {{ calendar_sync_body }} + {% endfilter %} +
+

+
+{% endblock %} diff --git a/common/templates/student/edx_ace/calendarsync/email/body.txt b/common/templates/student/edx_ace/calendarsync/email/body.txt new file mode 100644 index 0000000000..d2e6f46bb1 --- /dev/null +++ b/common/templates/student/edx_ace/calendarsync/email/body.txt @@ -0,0 +1,2 @@ +{% load i18n %}{% autoescape off %} +{{ calendar_sync_body }} diff --git a/common/templates/student/edx_ace/calendarsync/email/from_name.txt b/common/templates/student/edx_ace/calendarsync/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/common/templates/student/edx_ace/calendarsync/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/common/templates/student/edx_ace/calendarsync/email/head.html b/common/templates/student/edx_ace/calendarsync/email/head.html new file mode 100644 index 0000000000..366ada7ad9 --- /dev/null +++ b/common/templates/student/edx_ace/calendarsync/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/common/templates/student/edx_ace/calendarsync/email/subject.txt b/common/templates/student/edx_ace/calendarsync/email/subject.txt new file mode 100644 index 0000000000..7ef3d19b84 --- /dev/null +++ b/common/templates/student/edx_ace/calendarsync/email/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{{ calendar_sync_subject }} +{% endautoescape %} diff --git a/lms/envs/common.py b/lms/envs/common.py index 1df3a4345b..93232f7bff 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -515,6 +515,7 @@ PASSWORD_RESET_EMAIL_RATE_LIMIT = { 'no_of_emails': 1, 'per_seconds': 60 } +RETRY_CALENDAR_SYNC_EMAIL_MAX_ATTEMPTS = 5 # Deadline message configurations COURSE_MESSAGE_ALERT_DURATION_IN_DAYS = 14 diff --git a/openedx/features/calendar_sync/message_types.py b/openedx/features/calendar_sync/message_types.py new file mode 100644 index 0000000000..72af6e8009 --- /dev/null +++ b/openedx/features/calendar_sync/message_types.py @@ -0,0 +1,13 @@ +""" +ACE message types for the calendar_sync module. +""" + + +from openedx.core.djangoapps.ace_common.message import BaseMessageType + + +class CalendarSync(BaseMessageType): + def __init__(self, *args, **kwargs): + super(CalendarSync, self).__init__(*args, **kwargs) + + self.options['transactional'] = True diff --git a/openedx/features/calendar_sync/tasks.py b/openedx/features/calendar_sync/tasks.py new file mode 100644 index 0000000000..834d4e5c01 --- /dev/null +++ b/openedx/features/calendar_sync/tasks.py @@ -0,0 +1,71 @@ +""" +This file contains celery tasks for sending email +""" + + +import logging + +from celery.exceptions import MaxRetriesExceededError +from celery.task import task +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from edx_ace import ace +from edx_ace.errors import RecoverableChannelDeliveryError +from edx_ace.message import Message +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.lib.celery.task_utils import emulate_http_request + +log = logging.getLogger('edx.celery.task') + + +@task(bind=True) +def send_calendar_sync_email(self, msg_string, from_address=None): + """ + Sending calendar sync email to the user. + """ + msg = Message.from_string(msg_string) + + max_retries = settings.RETRY_CALENDAR_SYNC_EMAIL_MAX_ATTEMPTS + retries = self.request.retries + + if from_address is None: + from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS') or ( + configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + ) + msg.options['from_address'] = from_address + + dest_addr = msg.recipient.email_address + + site = Site.objects.get_current() + user = User.objects.get(username=msg.recipient.username) + + try: + with emulate_http_request(site=site, user=user): + ace.send(msg) + # Log that the Activation Email has been sent to user without an exception + log.info("Calendar Sync Email has been sent to User {user_email}".format( + user_email=dest_addr + )) + except RecoverableChannelDeliveryError: + log.info('Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( + dest_addr=dest_addr, + attempt=retries, + max_attempts=max_retries + )) + try: + self.retry(countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, max_retries=max_retries) + except MaxRetriesExceededError: + log.error( + 'Unable to send calendar sync email to user from "%s" to "%s"', + from_address, + dest_addr, + exc_info=True + ) + except Exception: + log.exception( + 'Unable to send calendar sync email to user from "%s" to "%s"', + from_address, + dest_addr, + ) + raise Exception diff --git a/openedx/features/calendar_sync/tests/test_tasks.py b/openedx/features/calendar_sync/tests/test_tasks.py new file mode 100644 index 0000000000..787e68f548 --- /dev/null +++ b/openedx/features/calendar_sync/tests/test_tasks.py @@ -0,0 +1,83 @@ +""" +Tests for the Sending activation email celery tasks +""" + + +import mock +from django.conf import settings +from django.test import TestCase +from six.moves import range + +from edx_ace.errors import ChannelError, RecoverableChannelDeliveryError +from lms.djangoapps.courseware.tests.factories import UserFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.features.calendar_sync.tasks import send_calendar_sync_email +from openedx.features.calendar_sync.views.management import compose_calendar_sync_email + + +class SendCalendarSyncEmailTestCase(TestCase): + """ + Test for send activation email to user + """ + def setUp(self): + """ Setup components used by each test.""" + super(SendCalendarSyncEmailTestCase, self).setUp() + self.user = UserFactory() + self.course_overview = CourseOverviewFactory() + self.msg = compose_calendar_sync_email(self.user, self.course_overview) + + @mock.patch('time.sleep', mock.Mock(return_value=None)) + @mock.patch('openedx.features.calendar_sync.tasks.log') + @mock.patch( + 'openedx.features.calendar_sync.tasks.ace.send', + mock.Mock(side_effect=RecoverableChannelDeliveryError(None, None)) + ) + def test_RetrySendUntilFail(self, mock_log): + """ + Tests retries when the activation email doesn't send + """ + from_address = 'task_testing@example.com' + email_max_attempts = settings.RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS + + send_calendar_sync_email.delay(str(self.msg), from_address=from_address) + + # Asserts sending email retry logging. + for attempt in range(email_max_attempts): + mock_log.info.assert_any_call( + 'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( + dest_addr=self.user.email, + attempt=attempt, + max_attempts=email_max_attempts + )) + self.assertEqual(mock_log.info.call_count, 6) + + # Asserts that the error was logged on crossing max retry attempts. + mock_log.error.assert_called_with( + 'Unable to send calendar sync email to user from "%s" to "%s"', + from_address, + self.user.email, + exc_info=True + ) + self.assertEqual(mock_log.error.call_count, 1) + + @mock.patch('openedx.features.calendar_sync.tasks.log') + @mock.patch('openedx.features.calendar_sync.tasks.ace.send', mock.Mock(side_effect=ChannelError)) + def test_UnrecoverableSendError(self, mock_log): + """ + Tests that a major failure of the send is logged + """ + from_address = 'task_testing@example.com' + + send_calendar_sync_email.delay(str(self.msg), from_address=from_address) + + # Asserts that the error was logged + mock_log.exception.assert_called_with( + 'Unable to send calendar sync email to user from "%s" to "%s"', + from_address, + self.user.email + ) + + # Assert that nothing else was logged + self.assertEqual(mock_log.info.call_count, 0) + self.assertEqual(mock_log.error.call_count, 0) + self.assertEqual(mock_log.exception.call_count, 1) diff --git a/openedx/features/calendar_sync/tests/test_views.py b/openedx/features/calendar_sync/tests/test_views.py index d9892c050d..85d730105b 100644 --- a/openedx/features/calendar_sync/tests/test_views.py +++ b/openedx/features/calendar_sync/tests/test_views.py @@ -8,7 +8,10 @@ import ddt from django.test import TestCase from django.urls import reverse +from lms.djangoapps.courseware.tests.factories import UserFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.features.calendar_sync.api import SUBSCRIBE, UNSUBSCRIBE +from openedx.features.calendar_sync.views.management import compose_calendar_sync_email from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -49,3 +52,39 @@ class TestCalendarSyncView(SharedModuleStoreTestCase, TestCase): response = self.client.post(self.calendar_sync_url, data) self.assertEqual(response.status_code, expected_status_code) self.assertIn(contained_text, str(response.content)) + + +@ddt.ddt +class CalendarSyncEmailTestCase(TestCase): + """ + Test for send activation email to user + """ + + @ddt.data(False, True) + def test_compose_calendar_sync_email(self, is_update): + """ + Tests that attributes of the message are being filled correctly in compose_activation_email + """ + user = UserFactory() + course_overview = CourseOverviewFactory() + course_name = course_overview.display_name + if is_update: + calendar_sync_subject = 'Updates for Your {course} Schedule'.format(course=course_name) + calendar_sync_headline = 'Update Your Calendar' + calendar_sync_body = ('Your assignment due dates for {course} were recently adjusted. Update your calendar' + 'with your new schedule to ensure that you stay on track!').format(course=course_name) + else: + calendar_sync_subject = 'Stay on Track' + calendar_sync_headline = 'Mark Your Calendar' + calendar_sync_body = ( + 'Sticking to a schedule is the best way to ensure that you successfully complete your ' + 'self-paced course. This schedule of assignment due dates for {course} will help you ' + 'stay on track!'.format(course=course_name)) + + msg = compose_calendar_sync_email(user, course_overview, is_update) + + self.assertEqual(msg.context['calendar_sync_subject'], calendar_sync_subject) + self.assertEqual(msg.context['calendar_sync_headline'], calendar_sync_headline) + self.assertEqual(msg.context['calendar_sync_body'], calendar_sync_body) + self.assertEqual(msg.recipient.username, user.username) + self.assertEqual(msg.recipient.email_address, user.email) diff --git a/openedx/features/calendar_sync/views/management.py b/openedx/features/calendar_sync/views/management.py new file mode 100644 index 0000000000..9e4527c796 --- /dev/null +++ b/openedx/features/calendar_sync/views/management.py @@ -0,0 +1,64 @@ +""" +Calendar Sync Email Management +""" + + +from django.utils.translation import ugettext_lazy as _ + +from edx_ace.recipient import Recipient +from student.models import CourseEnrollment, CourseOverview +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.features.calendar_sync.message_types import CalendarSync +from openedx.features.calendar_sync.tasks import send_calendar_sync_email + + +def compose_calendar_sync_email(user, course: CourseOverview, is_update=False): + """ + Construct all the required params for the calendar + sync email through celery task + """ + + course_name = course.display_name + if is_update: + calendar_sync_subject = _('Updates for Your {course} Schedule').format(course=course_name) + calendar_sync_headline = _('Update Your Calendar') + calendar_sync_body = _('Your assignment due dates for {course} were recently adjusted. Update your calendar' + 'with your new schedule to ensure that you stay on track!').format(course=course_name) + else: + calendar_sync_subject = _('Stay on Track') + calendar_sync_headline = _('Mark Your Calendar') + calendar_sync_body = _('Sticking to a schedule is the best way to ensure that you successfully complete your ' + 'self-paced course. This schedule of assignment due dates for {course} will help you ' + 'stay on track!').format(course=course_name) + email_context = { + 'calendar_sync_subject': calendar_sync_subject, + 'calendar_sync_headline': calendar_sync_headline, + 'calendar_sync_body': calendar_sync_body, + } + + msg = CalendarSync().personalize( + recipient=Recipient(user.username, user.email), + language=preferences_api.get_user_preference(user, LANGUAGE_KEY), + user_context=email_context, + ) + + return msg + + +def compose_and_send_calendar_sync_email(user, course: CourseOverview, is_update=False): + """ + Construct all the required params and send the activation email + through celery task + + Arguments: + user: current logged-in user + course: course overview object + is_update: if this should be an 'update' email + """ + if not CourseEnrollment.objects.filter(user=user, course=course).exists(): + return + + msg = compose_calendar_sync_email(user, course, is_update) + + send_calendar_sync_email.delay(str(msg)) diff --git a/openedx/features/course_experience/views/course_dates.py b/openedx/features/course_experience/views/course_dates.py index 91c2bbfd21..6ffa66bc99 100644 --- a/openedx/features/course_experience/views/course_dates.py +++ b/openedx/features/course_experience/views/course_dates.py @@ -52,7 +52,7 @@ class CourseDatesFragmentMobileView(CourseDatesFragmentView): def get(self, request, *args, **kwargs): if not request.user.is_authenticated: raise Http404 - + print('****************', CourseDatesFragmentMobileView.__dict__) return super(CourseDatesFragmentMobileView, self).get(request, *args, **kwargs) def css_dependencies(self):