Merge pull request #24285 from edx/ndalfonso/AA-142-calendar-sync-ses
AA-142 calendar sync ses
This commit is contained in:
@@ -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 %}
|
||||
@@ -1,2 +0,0 @@
|
||||
{% load i18n %}{% autoescape off %}
|
||||
{{ calendar_sync_body }}
|
||||
@@ -1 +0,0 @@
|
||||
{{ platform_name }}
|
||||
@@ -1 +0,0 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -1,4 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{{ calendar_sync_subject }}
|
||||
{% endautoescape %}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
79
openedx/features/calendar_sync/utils.py
Normal file
79
openedx/features/calendar_sync/utils.py
Normal 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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user