From 5b1d18149198d665101a0bcb35c565d651132bc2 Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso Date: Thu, 18 Jun 2020 12:05:02 -0400 Subject: [PATCH] AA-142 calendar sync ses - set up util function to use Amazon SES for sending calendar sync emails. - remove old sailthru code which we originally implemented for this functionality - include ADR on our decision to use SES instead of Sailthru. --- .../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 - .../0001-calendar-sync-emails-using-ses.rst | 24 ++++++ .../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 --------- openedx/features/calendar_sync/utils.py | 79 ++++++++++++++++++ .../calendar_sync/views/management.py | 64 -------------- 12 files changed, 103 insertions(+), 300 deletions(-) delete mode 100644 common/templates/student/edx_ace/calendarsync/email/body.html delete mode 100644 common/templates/student/edx_ace/calendarsync/email/body.txt delete mode 100644 common/templates/student/edx_ace/calendarsync/email/from_name.txt delete mode 100644 common/templates/student/edx_ace/calendarsync/email/head.html delete mode 100644 common/templates/student/edx_ace/calendarsync/email/subject.txt create mode 100644 openedx/features/calendar_sync/docs/decisions/0001-calendar-sync-emails-using-ses.rst delete mode 100644 openedx/features/calendar_sync/message_types.py delete mode 100644 openedx/features/calendar_sync/tasks.py delete mode 100644 openedx/features/calendar_sync/tests/test_tasks.py create mode 100644 openedx/features/calendar_sync/utils.py delete 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 deleted file mode 100644 index 9ee009defc..0000000000 --- a/common/templates/student/edx_ace/calendarsync/email/body.html +++ /dev/null @@ -1,22 +0,0 @@ -{% 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 deleted file mode 100644 index d2e6f46bb1..0000000000 --- a/common/templates/student/edx_ace/calendarsync/email/body.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% 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 deleted file mode 100644 index dcbc23c004..0000000000 --- a/common/templates/student/edx_ace/calendarsync/email/from_name.txt +++ /dev/null @@ -1 +0,0 @@ -{{ platform_name }} diff --git a/common/templates/student/edx_ace/calendarsync/email/head.html b/common/templates/student/edx_ace/calendarsync/email/head.html deleted file mode 100644 index 366ada7ad9..0000000000 --- a/common/templates/student/edx_ace/calendarsync/email/head.html +++ /dev/null @@ -1 +0,0 @@ -{% 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 deleted file mode 100644 index 7ef3d19b84..0000000000 --- a/common/templates/student/edx_ace/calendarsync/email/subject.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{{ calendar_sync_subject }} -{% endautoescape %} diff --git a/openedx/features/calendar_sync/docs/decisions/0001-calendar-sync-emails-using-ses.rst b/openedx/features/calendar_sync/docs/decisions/0001-calendar-sync-emails-using-ses.rst new file mode 100644 index 0000000000..3491383abf --- /dev/null +++ b/openedx/features/calendar_sync/docs/decisions/0001-calendar-sync-emails-using-ses.rst @@ -0,0 +1,24 @@ +1. Amazon SES for calendar sync emails +================================================ + +Status +------ + +Proposed + +Context +------- + +For calendar sync functionality, we would like to send users +emails with .ics file attachments, which will in turn allow the user +to easily add/update course deadline dates on their personal calendars. + +Decision +-------- + +We will use Amazon SES to send these emails. Originally, we had hoped to use +Sailthru, but found that file attachments were not supported. While emails +with attachments are not currently sent from platform at the time this doc is +being written, they are however sent from other services such as +enterprise-data using Amazon SES. We will use a similar approach for our +calendar sync feature. diff --git a/openedx/features/calendar_sync/message_types.py b/openedx/features/calendar_sync/message_types.py deleted file mode 100644 index 72af6e8009..0000000000 --- a/openedx/features/calendar_sync/message_types.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -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 deleted file mode 100644 index 834d4e5c01..0000000000 --- a/openedx/features/calendar_sync/tasks.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -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 deleted file mode 100644 index 787e68f548..0000000000 --- a/openedx/features/calendar_sync/tests/test_tasks.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -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 85d730105b..d9892c050d 100644 --- a/openedx/features/calendar_sync/tests/test_views.py +++ b/openedx/features/calendar_sync/tests/test_views.py @@ -8,10 +8,7 @@ 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 @@ -52,39 +49,3 @@ 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/utils.py b/openedx/features/calendar_sync/utils.py new file mode 100644 index 0000000000..7bade9b7fd --- /dev/null +++ b/openedx/features/calendar_sync/utils.py @@ -0,0 +1,79 @@ +import logging +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from django.conf import settings +from django.utils.html import format_html +from django.utils.translation import ugettext_lazy as _ +import os + +import boto3 + +logger = logging.getLogger(__name__) + + +def calendar_sync_initial_email_content(course_name): + subject = _('Stay on Track') + body_text = _('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) + body = format_html('
{text}
', text=body_text) + return subject, body + + +def calendar_sync_update_email_content(course_name): + subject = _('Updates for Your {course} Schedule').format(course=course_name) + body_text = _('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) + body = format_html('
{text}
', text=body_text) + return subject, body + + +def prepare_attachments(attachment_data): + """ + Helper function to create a list contain file attachment objects + for use with MIMEMultipart + Returns a list of MIMEApplication objects + """ + + attachments = [] + for filename, data in attachment_data.items(): + msg_attachment = MIMEApplication(data) + msg_attachment.add_header( + 'Content-Disposition', + 'attachment', + filename=os.path.basename(filename) + ) + msg_attachment.set_type('text/calendar') + attachments.append(msg_attachment) + + return attachments + + +def send_email_with_attachment(to_emails, attachment_data, course_name, is_update=False): + # connect to SES + client = boto3.client('ses', region_name=settings.AWS_SES_REGION_NAME) + + subject, body = (calendar_sync_update_email_content(course_name) if is_update else + calendar_sync_initial_email_content(course_name)) + + # build email body as html + msg_body = MIMEText(body, 'html') + + attachments = prepare_attachments(attachment_data) + + # iterate over each email in the list to send emails independently + for email in to_emails: + msg = MIMEMultipart() + msg['Subject'] = str(subject) + msg['From'] = settings.BULK_EMAIL_DEFAULT_FROM_EMAIL + msg['To'] = email + + # attach the message body and attachment + msg.attach(msg_body) + for msg_attachment in attachments: + msg.attach(msg_attachment) + + # send the email + result = client.send_raw_email(Source=msg['From'], Destinations=[email], RawMessage={'Data': msg.as_string()}) + logger.debug(result) diff --git a/openedx/features/calendar_sync/views/management.py b/openedx/features/calendar_sync/views/management.py deleted file mode 100644 index 9e4527c796..0000000000 --- a/openedx/features/calendar_sync/views/management.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -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))