Skip to content
Snippets Groups Projects
Unverified Commit 0b95c2d3 authored by Nick's avatar Nick Committed by GitHub
Browse files

Merge pull request #24451 from edx/AA-160-calendar-sync-initial-email

AA-160 calendar sync initial email
parents ef536e49 858c3750
No related branches found
Tags release-2020-12-18-19.16
No related merge requests found
......@@ -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
......
"""
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):
......
from django.contrib import admin
from .models import UserCalendarSyncConfig
admin.site.register(UserCalendarSyncConfig)
"""
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
# 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()
......
"""
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)
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:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment