Merge pull request #24451 from edx/AA-160-calendar-sync-initial-email
AA-160 calendar sync initial email
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
4
openedx/features/calendar_sync/admin.py
Normal file
4
openedx/features/calendar_sync/admin.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from django.contrib import admin
|
||||
from .models import UserCalendarSyncConfig
|
||||
|
||||
admin.site.register(UserCalendarSyncConfig)
|
||||
18
openedx/features/calendar_sync/apps.py
Normal file
18
openedx/features/calendar_sync/apps.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
|
||||
35
openedx/features/calendar_sync/signals.py
Normal file
35
openedx/features/calendar_sync/signals.py
Normal file
@@ -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)
|
||||
12
openedx/features/calendar_sync/tests/factories.py
Normal file
12
openedx/features/calendar_sync/tests/factories.py
Normal file
@@ -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
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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('<div>{text}</div>', 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(
|
||||
'<div style="margin-bottom:10px">{bp1}</div><div>{bp2}</div>',
|
||||
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('<div>{text}</div>', 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('<div>{text}</div>', 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:
|
||||
|
||||
Reference in New Issue
Block a user