From 858c3750b067ebf39dfb7482d419f348621f4ee5 Mon Sep 17 00:00:00 2001 From: Nicholas D'Alfonso Date: Mon, 29 Jun 2020 14:19:27 -0400 Subject: [PATCH] AA-160 calendar sync initial email -use Amazon SES to send calendar sync email when user initially subscribes to the calendar sync feature --- lms/djangoapps/courseware/courses.py | 9 ++-- openedx/features/calendar_sync/__init__.py | 1 + openedx/features/calendar_sync/admin.py | 4 ++ openedx/features/calendar_sync/apps.py | 18 ++++++++ openedx/features/calendar_sync/ics.py | 23 +++++++---- .../migrations/0002_auto_20200709_1743.py | 23 +++++++++++ openedx/features/calendar_sync/models.py | 1 + openedx/features/calendar_sync/signals.py | 35 ++++++++++++++++ .../features/calendar_sync/tests/factories.py | 12 ++++++ .../features/calendar_sync/tests/test_ics.py | 22 +++++++--- openedx/features/calendar_sync/utils.py | 41 +++++++++++-------- 11 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 openedx/features/calendar_sync/admin.py create mode 100644 openedx/features/calendar_sync/apps.py create mode 100644 openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py create mode 100644 openedx/features/calendar_sync/signals.py create mode 100644 openedx/features/calendar_sync/tests/factories.py diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 4beabc17b1..58db73568e 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -501,14 +501,14 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, if num_return is None in date increasing order. """ date_blocks = [] - for assignment in get_course_assignments(course.id, user, request, include_access=include_access): + for assignment in get_course_assignments(course.id, user, include_access=include_access): date_block = CourseAssignmentDate(course, user) date_block.date = assignment.date date_block.contains_gated_content = assignment.contains_gated_content date_block.complete = assignment.complete date_block.assignment_type = assignment.assignment_type date_block.past_due = assignment.past_due - date_block.link = assignment.url + date_block.link = request.build_absolute_uri(assignment.url) if assignment.url else '' date_block.set_title(assignment.title, link=assignment.url) date_blocks.append(date_block) date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn) @@ -518,7 +518,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, @request_cached() -def get_course_assignments(course_key, user, request, include_access=False): +def get_course_assignments(course_key, user, include_access=False): """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. @@ -544,12 +544,11 @@ def get_course_assignments(course_key, user, request, include_access=False): assignment_type = block_data.get_xblock_field(subsection_key, 'format', None) - url = '' + url = None start = block_data.get_xblock_field(subsection_key, 'start') assignment_released = not start or start < now if assignment_released: url = reverse('jump_to', args=[course_key, subsection_key]) - url = request and request.build_absolute_uri(url) complete = block_data.get_xblock_field(subsection_key, 'complete', False) past_due = not complete and due < now diff --git a/openedx/features/calendar_sync/__init__.py b/openedx/features/calendar_sync/__init__.py index 77fd2dd0b2..47f76c832d 100644 --- a/openedx/features/calendar_sync/__init__.py +++ b/openedx/features/calendar_sync/__init__.py @@ -1,6 +1,7 @@ """ Calendar syncing Course dates with a User. """ +default_app_config = 'openedx.features.calendar_sync.apps.UserCalendarSyncConfig' def get_calendar_event_id(user, block_key, date_type, hostname): diff --git a/openedx/features/calendar_sync/admin.py b/openedx/features/calendar_sync/admin.py new file mode 100644 index 0000000000..02609df7da --- /dev/null +++ b/openedx/features/calendar_sync/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import UserCalendarSyncConfig + +admin.site.register(UserCalendarSyncConfig) diff --git a/openedx/features/calendar_sync/apps.py b/openedx/features/calendar_sync/apps.py new file mode 100644 index 0000000000..ac835080de --- /dev/null +++ b/openedx/features/calendar_sync/apps.py @@ -0,0 +1,18 @@ +""" +Define the calendar_sync Django App. +""" + +# -*- coding: utf-8 -*- + + +from django.apps import AppConfig + + +class UserCalendarSyncConfig(AppConfig): + name = 'openedx.features.calendar_sync' + + def ready(self): + super(UserCalendarSyncConfig, self).ready() + + # noinspection PyUnresolvedReferences + import openedx.features.calendar_sync.signals # pylint: disable=import-outside-toplevel,unused-import diff --git a/openedx/features/calendar_sync/ics.py b/openedx/features/calendar_sync/ics.py index 706b092768..fc465443e7 100644 --- a/openedx/features/calendar_sync/ics.py +++ b/openedx/features/calendar_sync/ics.py @@ -9,12 +9,13 @@ from icalendar import Calendar, Event, vCalAddress, vText from lms.djangoapps.courseware.courses import get_course_assignments from openedx.core.djangoapps.site_configuration.helpers import get_value +from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangolib.markup import HTML from . import get_calendar_event_id -def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, organizer_email): +def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, organizer_email, config): """ Generates an ics-formatted bytestring for the given assignment information. @@ -36,6 +37,7 @@ def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, event.add('dtstart', start) event.add('duration', timedelta(0)) event.add('transp', 'TRANSPARENT') # available, rather than busy + event.add('sequence', config.ics_sequence) cal = Calendar() cal.add('prodid', '-//Open edX//calendar_sync//EN') @@ -46,28 +48,31 @@ def generate_ics_for_event(uid, title, course_name, now, start, organizer_name, return cal.to_ical() -def generate_ics_for_user_course(course, user, request): +def generate_ics_files_for_user_course(course, user, user_calendar_sync_config_instance): """ Generates ics-formatted bytestrings of all assignments for a given course and user. To pretty-print each bytestring, do: `ics.decode('utf8').replace('\r\n', '\n')` - Returns an iterable of ics files, each one representing an assignment. + Returns a dictionary of ics files, each one representing an assignment. """ - assignments = get_course_assignments(course.id, user, request) + assignments = get_course_assignments(course.id, user) platform_name = get_value('platform_name', settings.PLATFORM_NAME) platform_email = get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) now = datetime.now(pytz.utc) + site_config = SiteConfiguration.get_configuration_for_org(course.org) - return ( - generate_ics_for_event( + ics_files = {} + for assignment in assignments: + ics_files[assignment.title] = generate_ics_for_event( now=now, organizer_name=platform_name, organizer_email=platform_email, start=assignment.date, title=assignment.title, course_name=course.display_name_with_default, - uid=get_calendar_event_id(user, str(assignment.block_key), 'due', request.site.domain), + uid=get_calendar_event_id(user, str(assignment.block_key), 'due', site_config.site.domain), + config=user_calendar_sync_config_instance, ) - for assignment in assignments - ) + + return ics_files diff --git a/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py b/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py new file mode 100644 index 0000000000..5b78da35c7 --- /dev/null +++ b/openedx/features/calendar_sync/migrations/0002_auto_20200709_1743.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.14 on 2020-07-09 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_sync', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='historicalusercalendarsyncconfig', + name='ics_sequence', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='usercalendarsyncconfig', + name='ics_sequence', + field=models.IntegerField(default=0), + ), + ] diff --git a/openedx/features/calendar_sync/models.py b/openedx/features/calendar_sync/models.py index 49608c6a42..b3c817575a 100644 --- a/openedx/features/calendar_sync/models.py +++ b/openedx/features/calendar_sync/models.py @@ -19,6 +19,7 @@ class UserCalendarSyncConfig(models.Model): user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) course_key = CourseKeyField(max_length=255, db_index=True) enabled = models.BooleanField(default=False) + ics_sequence = models.IntegerField(default=0) history = HistoricalRecords() diff --git a/openedx/features/calendar_sync/signals.py b/openedx/features/calendar_sync/signals.py new file mode 100644 index 0000000000..86915623f0 --- /dev/null +++ b/openedx/features/calendar_sync/signals.py @@ -0,0 +1,35 @@ +""" +Signal handler for calendar sync models +""" +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.features.course_experience import CALENDAR_SYNC_FLAG, RELATIVE_DATES_FLAG + +from .ics import generate_ics_files_for_user_course +from .models import UserCalendarSyncConfig +from .utils import send_email_with_attachment + + +@receiver(post_save, sender=UserCalendarSyncConfig) +def handle_calendar_sync_email(sender, instance, created, **kwargs): + if ( + CALENDAR_SYNC_FLAG.is_enabled(instance.course_key) and + RELATIVE_DATES_FLAG.is_enabled(instance.course_key) and + created + ): + user = instance.user + email = user.email + course_overview = CourseOverview.objects.get(id=instance.course_key) + ics_files = generate_ics_files_for_user_course(course_overview, user, instance) + send_email_with_attachment( + [email], + ics_files, + course_overview.display_name, + created + ) + post_save.disconnect(handle_calendar_sync_email, sender=UserCalendarSyncConfig) + instance.ics_sequence = instance.ics_sequence + 1 + instance.save() + post_save.connect(handle_calendar_sync_email, sender=UserCalendarSyncConfig) diff --git a/openedx/features/calendar_sync/tests/factories.py b/openedx/features/calendar_sync/tests/factories.py new file mode 100644 index 0000000000..4240219620 --- /dev/null +++ b/openedx/features/calendar_sync/tests/factories.py @@ -0,0 +1,12 @@ +from factory.django import DjangoModelFactory +from openedx.features.calendar_sync.models import UserCalendarSyncConfig + + +class UserCalendarSyncConfigFactory(DjangoModelFactory): + """ + Factory class for SiteConfiguration model + """ + class Meta(object): + model = UserCalendarSyncConfig + + enabled = True diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py index bd0892ce36..8e053134d1 100644 --- a/openedx/features/calendar_sync/tests/test_ics.py +++ b/openedx/features/calendar_sync/tests/test_ics.py @@ -9,9 +9,10 @@ from mock import patch from lms.djangoapps.courseware.courses import _Assignment from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory from openedx.features.calendar_sync import get_calendar_event_id -from openedx.features.calendar_sync.ics import generate_ics_for_user_course +from openedx.features.calendar_sync.ics import generate_ics_files_for_user_course +from openedx.features.calendar_sync.tests.factories import UserCalendarSyncConfigFactory from student.tests.factories import UserFactory @@ -30,6 +31,13 @@ class TestIcsGeneration(TestCase): self.request = RequestFactory().request() self.request.site = SiteFactory() self.request.user = self.user + self.site_config = SiteConfigurationFactory.create( + site_values={'course_org_filter': self.course.org} + ) + self.user_calendar_sync_config = UserCalendarSyncConfigFactory.create( + user=self.user, + course_key=self.course.id, + ) def make_assigment( self, block_key=None, title=None, url=None, date=None, contains_gated_content=False, complete=False, @@ -50,6 +58,7 @@ DTSTART;VALUE=DATE-TIME:{timedue} DURATION:P0D DTSTAMP;VALUE=DATE-TIME:20131003T082455Z UID:{uid} +SEQUENCE:{sequence} DESCRIPTION:{summary} is due for {course}. ORGANIZER;CN=édX:mailto:registration@example.com TRANSP:TRANSPARENT @@ -61,7 +70,8 @@ END:VCALENDAR summary=assignment.title, course=self.course.display_name_with_default, timedue=assignment.date.strftime('%Y%m%dT%H%M%SZ'), - uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.request.site.domain), + uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.site_config.site.domain), + sequence=self.user_calendar_sync_config.ics_sequence ) for assignment in assignments ) @@ -70,11 +80,13 @@ END:VCALENDAR """ Uses generate_ics_for_user_course to create ics files for the given assignments """ with patch('openedx.features.calendar_sync.ics.get_course_assignments') as mock_get_assignments: mock_get_assignments.return_value = assignments - return generate_ics_for_user_course(self.course, self.user, self.request) + return generate_ics_files_for_user_course(self.course, self.user, self.user_calendar_sync_config) def assert_ics(self, *assignments): """ Asserts that the generated and expected ics for the given assignments are equal """ - generated = [ics.decode('utf8').replace('\r\n', '\n') for ics in self.generate_ics(*assignments)] + generated = [ + file.decode('utf8').replace('\r\n', '\n') for file in sorted(self.generate_ics(*assignments).values()) + ] self.assertEqual(len(generated), len(assignments)) self.assertListEqual(generated, list(self.expected_ics(*assignments))) diff --git a/openedx/features/calendar_sync/utils.py b/openedx/features/calendar_sync/utils.py index 7bade9b7fd..4ecbbf6285 100644 --- a/openedx/features/calendar_sync/utils.py +++ b/openedx/features/calendar_sync/utils.py @@ -6,43 +6,50 @@ 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) + subject = _('Sync {course} to your calendar').format(course=course_name) + body_paragraph_1 = _('Sticking to a schedule is the best way to ensure that you successfully complete your ' + 'self-paced course. This schedule for {course} will help you stay on track!' + ).format(course=course_name) + body_paragraph_2 = _('Once you sync your course schedule to your calendar, any updates to the course from your ' + 'instructor will be automatically reflected. You can remove the course from your calendar ' + 'at any time.') + body = format_html( + '
{bp1}
{bp2}
', + bp1=body_paragraph_1, + bp2=body_paragraph_2 + ) + 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) + subject = _('{course} dates have been updated on your calendar').format(course=course_name) + body_paragraph = _('You have successfully shifted your course schedule and your calendar is up to date.' + ).format(course=course_name) + body = format_html('
{text}
', text=body_paragraph) + return subject, body -def prepare_attachments(attachment_data): +def prepare_attachments(attachment_data, file_ext=''): """ 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) + filename=os.path.basename(filename) + file_ext ) msg_attachment.set_type('text/calendar') attachments.append(msg_attachment) @@ -50,17 +57,17 @@ def prepare_attachments(attachment_data): return attachments -def send_email_with_attachment(to_emails, attachment_data, course_name, is_update=False): +def send_email_with_attachment(to_emails, attachment_data, course_name, is_initial): # 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)) + subject, body = (calendar_sync_initial_email_content(course_name) if is_initial else + calendar_sync_update_email_content(course_name)) # build email body as html msg_body = MIMEText(body, 'html') - attachments = prepare_attachments(attachment_data) + attachments = prepare_attachments(attachment_data, '.ics') # iterate over each email in the list to send emails independently for email in to_emails: