Merge pull request #24285 from edx/ndalfonso/AA-142-calendar-sync-ses

AA-142 calendar sync ses
This commit is contained in:
Nick
2020-06-23 13:52:42 -04:00
committed by GitHub
12 changed files with 103 additions and 300 deletions

View File

@@ -1,22 +0,0 @@
{% extends 'ace_common/edx_ace/common/base_body.html' %}
{% load django_markup %}
{% load i18n %}
{% load static %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>
{% filter force_escape %}{{ calendar_sync_headline }}{% endfilter %}
</h1>
<p style="color: rgba(0,0,0,.75);">
{% filter force_escape %}
{{ calendar_sync_body }}
{% endfilter %}
<br />
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -1,2 +0,0 @@
{% load i18n %}{% autoescape off %}
{{ calendar_sync_body }}

View File

@@ -1 +0,0 @@
{{ platform_name }}

View File

@@ -1 +0,0 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}

View File

@@ -1,4 +0,0 @@
{% load i18n %}
{% autoescape off %}
{{ calendar_sync_subject }}
{% endautoescape %}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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('<div>{text}</div>', 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('<div>{text}</div>', 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)

View File

@@ -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))