Merge branch 'master' into revert-edx-enterprise
This commit is contained in:
@@ -197,7 +197,6 @@ class MigrationTests(TestCase):
|
||||
Tests for migrations.
|
||||
"""
|
||||
|
||||
@unittest.skip('migration is purposely out of sync with models atm.')
|
||||
@override_settings(MIGRATION_MODULES={})
|
||||
def test_migrations_are_in_sync(self):
|
||||
"""
|
||||
|
||||
178
docs/decisions/0005-studio-lms-subdomain-boundaries.rst
Normal file
178
docs/decisions/0005-studio-lms-subdomain-boundaries.rst
Normal file
@@ -0,0 +1,178 @@
|
||||
Status
|
||||
======
|
||||
|
||||
Proposed
|
||||
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
The ``edx-platform`` repo contains both Studio and the LMS for Open edX. These
|
||||
two systems roughly correspond to the Content Authoring and Learning subdomains,
|
||||
but the precise separation of responsibilities is currently unclear in many
|
||||
cases. This ADR is intended to clarify those boundaries and offer guidelines for
|
||||
how developers should compose new functionality across these systems, as well as
|
||||
providing a direction for migrating existing functionality over the long term.
|
||||
|
||||
Note that it is likely that we'll further separate content authoring (e.g.
|
||||
content libraries) from course run administration (e.g. grading policy). It's
|
||||
possible that both of these will evolve under the umbrella of what users see as
|
||||
"Studio". Even if that happens, there will still be an architectural split
|
||||
between the Content Authoring and Learning subdomains within that new Studio.
|
||||
|
||||
|
||||
Decision
|
||||
========
|
||||
|
||||
The high level guidelines for the interaction between the Content Authoring and
|
||||
Learning subdomains currently represented by Studio and LMS are:
|
||||
|
||||
* Studio should not store Learner information.
|
||||
* Studio and LMS should use different representations of content.
|
||||
* Decouple content grouping concepts from user/learning grouping concepts.
|
||||
* Studio Content data acts as an input to LMS policy and Learner Experience data.
|
||||
* LMS data should not flow backward into Studio.
|
||||
* Content Authoring changes require explicit publishing of versioned data.
|
||||
|
||||
|
||||
Studio should not store Learner information.
|
||||
--------------------------------------------
|
||||
|
||||
Studio's responsibility centers around the content itself. It should not store
|
||||
information about students, which brings with it many other concerns around
|
||||
data sensitivity and scale.
|
||||
|
||||
|
||||
Studio and LMS should use different representations of content.
|
||||
--------------------------------------------------------------------
|
||||
|
||||
Content authoring will require versioned storage of data, ownership tracking,
|
||||
tagging, and other metadata. The LMS focuses on read-optimization at a much
|
||||
higher scale. We've long suffered from added code complexity and performance
|
||||
issues by trying to cover both usage patterns with ModuleStore.
|
||||
|
||||
We have already taken steps to create a read-optimized store in the form of
|
||||
Block Transformers. We should continue this practice and encourage the LMS to
|
||||
transform content at publish-time into whatever representation its various
|
||||
systems (courseware, grading, scheduling, etc.) require to be performant.
|
||||
|
||||
|
||||
Decouple content grouping concepts from user/learning grouping concepts.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
A common use case for course content is to show different bits of content to
|
||||
different cohorts of users. For instance, a university might have a licensing
|
||||
agreement that allows it to show a set of vidoes only to its own staff and
|
||||
students, and not a wider MOOC audience. Studio needs to be able to annotate
|
||||
this data somehow, but the list of available cohorts for a given course is
|
||||
considered Learner information that may change from run to run.
|
||||
|
||||
We solve this by using a level of indirection. Studio doesn't map content into
|
||||
Cohorts of students (an LMS concept). It maps content into Content Groups. The
|
||||
LMS is then responsible for both the creation of Cohorts as well as the mapping
|
||||
of Content Groups to Cohorts.
|
||||
|
||||
While this might sound a little cumbersome, it actually allows for a cleaner
|
||||
separation of concerns. Content Groups describe what the content is: restricted
|
||||
copyright, advanced material, labratory exercises, etc. Cohorts describe who is
|
||||
consuming that material: on campus students, alumni, the general MOOC audience,
|
||||
etc. The Content Group is an Authoring decision based on the properties of the
|
||||
content itself. The Cohort mapping is a policy decision about the Learning
|
||||
experience of a particular set of students.
|
||||
|
||||
Furthermore, the mapping of Content Groups to Cohorts is not 1:1. You could
|
||||
decide that both on-campus students and alumni get the same content group
|
||||
experience, while keeping those Cohorts separate for the purposes of other parts
|
||||
of the LMS like forums discussions.
|
||||
|
||||
A more future looking example might be the interaction between Open edX
|
||||
courseware and third party forum services. The fact that certain units are
|
||||
marked as discussable topics might be a Content Authoring decision in Studio,
|
||||
while the choice of which forum service those discussions happen in might be a
|
||||
Learning decision in the LMS.
|
||||
|
||||
|
||||
Studio Content data acts as an input to LMS policy and Learner Experience data.
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
As courseware becomes more dynamic, certain concepts in the LMS are becoming
|
||||
richer than their equivalent concepts in Studio. In these situations, we should
|
||||
think of the data relationship as a one way flow of data from Studio to the LMS.
|
||||
The LMS takes Studio data as an input that it can enrich, transform, or override
|
||||
as necessary to create the desired student learning experience.
|
||||
|
||||
Content scheduling is a good example of this. In the early days of Open edX,
|
||||
course teams would set start and due dates for subsections in Studio, and that
|
||||
would be the end of it. Today, we have personalized schedules, individual due
|
||||
date extensions, and more. The pattern we use to accomplish this is:
|
||||
|
||||
* Copy the schedule information from Studio to the LMS at the time a course is
|
||||
published, transforming it into a more easily queryable form in the process.
|
||||
* Add additional data models in the LMS to support use cases like individual due
|
||||
date extensions and personalized rescheduling. This is currently handled by
|
||||
the edx_when app, developed in the edx-when repository.
|
||||
* Remap field data so that XBlocks in the LMS runtime query this richer data
|
||||
model. Accessing an XBlock's ``start`` or ``due`` attribute in the Studio
|
||||
runtime continues to work with simple key/values in ModuleStore, but the LMS
|
||||
XBlock runtime will fetch those values from edx-when's in-process API.
|
||||
|
||||
This approach allows us to add flexibility to the LMS, while preserving
|
||||
backwards compatibility with existing XBlock content.
|
||||
|
||||
|
||||
LMS data should not flow backward into Studio.
|
||||
----------------------------------------------
|
||||
|
||||
Since LMS concepts extend Studio ones, we don't want changes to flow backwards
|
||||
from the LMS back into Studio. Some reasons:
|
||||
|
||||
* There is no guarantee that Studio course runs will be 1:1 with LMS course
|
||||
runs. In fact, one to many mappings of course runs already exist if CCX
|
||||
courses are enabled.
|
||||
* A unidirectional data flow makes the system easier to debug and reason about.
|
||||
* The OLX import/export process stays much simpler if it doesn't have to
|
||||
consider data that the LMS has added.
|
||||
|
||||
|
||||
Content Authoring changes require explicit publishing of versioned data.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Changes to content data should be marked with an explicit, versioned publishing
|
||||
step. Many LMS systems update their representations of content data based on
|
||||
publish signals from Studio today. Studio also needs to differentiate draft
|
||||
changes authors want to make from changes that are ready for student use.
|
||||
|
||||
The LMS is permitted to modify the learning experience without any such explicit
|
||||
publishing step. Deadlines may pass, blocking off student access to certain
|
||||
parts of a course. Individual students may be placed into different teams or
|
||||
cohorts, given extensions, re-graded, etc.
|
||||
|
||||
|
||||
Goals
|
||||
=====
|
||||
|
||||
* Developers will have a clearer understanding of where to build authoring and
|
||||
learning experience functionality.
|
||||
* Improved separation of these subdomains will allow for easier debugging and
|
||||
better performance.
|
||||
* Decoupling these subdomains will allow for more rapid interation and
|
||||
innovation.
|
||||
|
||||
|
||||
Alternatives Considered
|
||||
=======================
|
||||
|
||||
An early alternative approach (that periodically resurfaces) is to make the
|
||||
content editing and publishing process happen in a much more integrated way. The
|
||||
learning and authoring experience blend together so closely that the author is
|
||||
essentially looking at the same interface as the student, supplemented with an
|
||||
edit button to modify thing in-line.
|
||||
|
||||
This approach was rejected early on because:
|
||||
|
||||
* Authoring needs differed in the workflow and information that they had to
|
||||
surface to course authors.
|
||||
* Separating the authoring and student experience allows multiple authoring
|
||||
systems (e.g. GitHub based OLX authoring).
|
||||
* At various points, the content authoring experience has been owned by a
|
||||
different team than the learning experience.
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Status
|
||||
------
|
||||
|
||||
Proposed
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Status
|
||||
------
|
||||
|
||||
Proposed
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
@@ -42,9 +42,9 @@ Here are a few organizational relationships that exist in the edX system:
|
||||
do so either via
|
||||
|
||||
a. bulk APIs using the *Client Credentials grant type* (e.g., to
|
||||
synchronize their own data in a background process) or
|
||||
synchronize their own data in a background process) or
|
||||
|
||||
b. a user-specific API on behalf of a logged-in user via the
|
||||
b. a user-specific API on behalf of a logged-in user via the
|
||||
*Authorization grant type* and *edX as the identity provider*
|
||||
(e.g., to display user-specific data on their own portal).
|
||||
|
||||
@@ -100,7 +100,7 @@ organization information to the granting end-user.
|
||||
|
||||
"content_org:Microsoft"
|
||||
|
||||
* For a token created on behalf of a user (*not* created via a
|
||||
* For a token created on behalf of a user (*not* created via a
|
||||
*Client Credentials grant type*), the token
|
||||
is further restricted specifically for the granting user. And so, a
|
||||
"user" filter with the value "me" would be added for this grant type.
|
||||
@@ -137,7 +137,7 @@ Token Examples
|
||||
Client Credentials (server-to-server) grant type
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When a trusted application makes server-to-server calls, the application's
|
||||
When a trusted application makes server-to-server calls, the application's
|
||||
service user info is included in the JWT and the *filters* field
|
||||
includes the organization identifier and type associated with the application.
|
||||
|
||||
@@ -166,7 +166,7 @@ filter “me” in the *filters* field.
|
||||
"version": "1.0",
|
||||
"preferred_username": "ajay_mehta",
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Consequences
|
||||
------------
|
||||
@@ -191,7 +191,7 @@ Consequences
|
||||
make sure there are no security issues introduced where old endpoints
|
||||
that are not aware of the new filter do not enforce it. Possible
|
||||
ways of doing so are:
|
||||
|
||||
|
||||
* Endpoints that are highly security sensitive should reject any
|
||||
token that includes an unrecognized filter.
|
||||
|
||||
@@ -206,7 +206,7 @@ Consequences
|
||||
they don't recognize. For example:
|
||||
|
||||
"grades:read:content_org"
|
||||
|
||||
|
||||
Additionally, this alternative would allow tokens to specify different filters
|
||||
for different scopes.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
-e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/github.in
|
||||
-e common/lib/capa # via -r requirements/edx/local.in
|
||||
-e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/github.in
|
||||
-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/github.in
|
||||
-e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/github.in
|
||||
-e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/github.in
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/github.in
|
||||
@@ -111,7 +111,7 @@ edx-rest-api-client==5.2.1 # via -r requirements/edx/base.in, edx-enterprise, e
|
||||
edx-search==1.4.1 # via -r requirements/edx/base.in
|
||||
edx-sga==0.11.0 # via -r requirements/edx/base.in
|
||||
edx-submissions==3.1.11 # via -r requirements/edx/base.in, ora2
|
||||
edx-tincan-py35==0.0.5 # via edx-enterprise
|
||||
edx-tincan-py35==0.0.9 # via edx-enterprise
|
||||
edx-user-state-client==1.2.0 # via -r requirements/edx/base.in
|
||||
edx-when==1.2.9 # via -r requirements/edx/base.in, edx-proctoring
|
||||
edxval==1.3.8 # via -r requirements/edx/base.in
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
-e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/testing.txt
|
||||
-e common/lib/capa # via -r requirements/edx/testing.txt
|
||||
-e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/testing.txt
|
||||
-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/testing.txt
|
||||
-e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/testing.txt
|
||||
-e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/testing.txt
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/testing.txt
|
||||
@@ -125,7 +125,7 @@ edx-search==1.4.1 # via -r requirements/edx/testing.txt
|
||||
edx-sga==0.11.0 # via -r requirements/edx/testing.txt
|
||||
edx-sphinx-theme==1.5.0 # via -r requirements/edx/development.in
|
||||
edx-submissions==3.1.11 # via -r requirements/edx/testing.txt, ora2
|
||||
edx-tincan-py35==0.0.5 # via -r requirements/edx/testing.txt, edx-enterprise
|
||||
edx-tincan-py35==0.0.9 # via -r requirements/edx/testing.txt, edx-enterprise
|
||||
edx-user-state-client==1.2.0 # via -r requirements/edx/testing.txt
|
||||
edx-when==1.2.9 # via -r requirements/edx/testing.txt, edx-proctoring
|
||||
edxval==1.3.8 # via -r requirements/edx/testing.txt
|
||||
|
||||
@@ -59,7 +59,7 @@ git+https://github.com/edx/MongoDBProxy.git@d92bafe9888d2940f647a7b2b2383b29c752
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme
|
||||
|
||||
# Our libraries:
|
||||
-e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail
|
||||
-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0
|
||||
-e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock
|
||||
-e git+https://github.com/edx/RateXBlock.git@2.0#egg=rate-xblock
|
||||
-e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#
|
||||
-e git+https://github.com/edx/acid-block.git@98aecba94ecbfa934e2d00262741c0ea9f557fc9#egg=acid-xblock # via -r requirements/edx/base.txt
|
||||
-e common/lib/capa # via -r requirements/edx/base.txt
|
||||
-e git+https://github.com/edx/codejail.git@4127fc4bd5775cc72aee8d7f0a70e31405e22439#egg=codejail # via -r requirements/edx/base.txt
|
||||
-e git+https://github.com/edx/codejail.git@56abd45fcf68c6ab7b523768aa5991d6e04d8241#egg=codejail==3.0.0 # via -r requirements/edx/base.txt
|
||||
-e git+https://github.com/edx/django-wiki.git@0.0.27#egg=django-wiki # via -r requirements/edx/base.txt
|
||||
-e git+https://github.com/edx/DoneXBlock.git@2.0.2#egg=done-xblock # via -r requirements/edx/base.txt
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme # via -r requirements/edx/base.txt
|
||||
@@ -121,7 +121,7 @@ edx-rest-api-client==5.2.1 # via -r requirements/edx/base.txt, edx-enterprise,
|
||||
edx-search==1.4.1 # via -r requirements/edx/base.txt
|
||||
edx-sga==0.11.0 # via -r requirements/edx/base.txt
|
||||
edx-submissions==3.1.11 # via -r requirements/edx/base.txt, ora2
|
||||
edx-tincan-py35==0.0.5 # via -r requirements/edx/base.txt, edx-enterprise
|
||||
edx-tincan-py35==0.0.9 # via -r requirements/edx/base.txt, edx-enterprise
|
||||
edx-user-state-client==1.2.0 # via -r requirements/edx/base.txt
|
||||
edx-when==1.2.9 # via -r requirements/edx/base.txt, edx-proctoring
|
||||
edxval==1.3.8 # via -r requirements/edx/base.txt
|
||||
|
||||
Reference in New Issue
Block a user