diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index d67c4e6c6b..ba1fe06e72 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -5,7 +5,7 @@ courseware.
import logging
-from collections import defaultdict
+from collections import defaultdict, namedtuple
from datetime import datetime
import pytz
@@ -63,6 +63,10 @@ from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITIO
log = logging.getLogger(__name__)
+# Used by get_course_assignments below. You shouldn't need to use this type directly.
+_Assignment = namedtuple('Assignment', ['block_key', 'title', 'url', 'date', 'requires_full_access'])
+
+
def get_course(course_id, depth=0):
"""
Given a course id, return the corresponding course descriptor.
@@ -415,7 +419,7 @@ def get_course_date_blocks(course, user, request=None, include_access=False,
blocks = [cls(course, user) for cls in block_classes]
if RELATIVE_DATES_FLAG.is_enabled(course.id):
blocks.append(CourseExpiredDate(course, user))
- blocks.extend(get_course_assignment_due_dates(
+ blocks.extend(get_course_assignment_date_blocks(
course, user, request, num_return=num_assignments,
include_access=include_access, include_past_dates=include_past_dates,
))
@@ -431,45 +435,61 @@ def date_block_key_fn(block):
return block.date or datetime.max.replace(tzinfo=pytz.UTC)
-def get_course_assignment_due_dates(course, user, request, num_return=None,
- include_past_dates=False, include_access=False):
+def get_course_assignment_date_blocks(course, user, request, num_return=None,
+ include_past_dates=False, include_access=False):
"""
Returns a list of assignment (at the subsection/sequential level) due date
blocks for the given course. Will return num_return results or all results
if num_return is None in date increasing order.
"""
- store = modulestore()
- all_course_dates = get_dates_for_course(course.id, user)
date_blocks = []
- for (block_key, date_type), date in all_course_dates.items():
- if date_type == 'due' and block_key.block_type == 'sequential':
- try:
- item = store.get_item(block_key)
- except ItemNotFoundError:
- continue
- if item.graded:
- date_block = CourseAssignmentDate(course, user)
- date_block.date = date
-
- if include_access:
- date_block.requires_full_access = _requires_full_access(store, user, block_key)
-
- block_url = None
- now = datetime.now().replace(tzinfo=pytz.UTC)
- assignment_released = item.start < now if item.start else True
- if assignment_released:
- block_url = reverse('jump_to', args=[course.id, block_key])
- block_url = request.build_absolute_uri(block_url) if request else None
- assignment_title = item.display_name if item.display_name else _('Assignment')
- date_block.set_title(assignment_title, link=block_url)
-
- date_blocks.append(date_block)
+ for assignment in get_course_assignments(course.id, user, request, include_access=include_access):
+ date_block = CourseAssignmentDate(course, user)
+ date_block.date = assignment.date
+ date_block.requires_full_access = assignment.requires_full_access
+ 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)
if num_return:
return date_blocks[:num_return]
return date_blocks
+def get_course_assignments(course_key, user, request, include_access=False):
+ """
+ Returns a list of assignment (at the subsection/sequential level) due dates for the given course.
+
+ Each returned object is a namedtuple with fields: block_key, title, url, date, requires_full_access
+ """
+ store = modulestore()
+ all_course_dates = get_dates_for_course(course_key, user)
+ assignments = []
+ for (block_key, date_type), date in all_course_dates.items():
+ if date_type != 'due' or block_key.block_type != 'sequential':
+ continue
+
+ try:
+ item = store.get_item(block_key)
+ except ItemNotFoundError:
+ continue
+
+ if not item.graded:
+ continue
+
+ requires_full_access = include_access and _requires_full_access(store, user, block_key)
+ title = item.display_name or _('Assignment')
+
+ url = None
+ assignment_released = not item.start or item.start < datetime.now(pytz.UTC)
+ if assignment_released:
+ url = reverse('jump_to', args=[course_key, block_key])
+ url = request and request.build_absolute_uri(url)
+
+ assignments.append(_Assignment(block_key, title, url, date, requires_full_access))
+
+ return assignments
+
+
def _requires_full_access(store, user, block_key):
"""
Returns a boolean if any child of the block_key specified has a group_access array consisting of just full_access
diff --git a/openedx/features/calendar_sync/__init__.py b/openedx/features/calendar_sync/__init__.py
index f83d0001c5..77fd2dd0b2 100644
--- a/openedx/features/calendar_sync/__init__.py
+++ b/openedx/features/calendar_sync/__init__.py
@@ -3,7 +3,7 @@ Calendar syncing Course dates with a User.
"""
-def get_calendar_event_id(user, block_key, date_type):
+def get_calendar_event_id(user, block_key, date_type, hostname):
"""
Creates a unique event id based on a user and a course block key
@@ -11,7 +11,8 @@ def get_calendar_event_id(user, block_key, date_type):
user (User): The user requesting a calendar event
block_key (str): The block key containing the date for the calendar event
date_type (str): The type of the date (e.g. 'due', 'start', 'end', etc.)
+ hostname (str): A hostname to namespace this id (e.g. 'open.edx.org')
Returns:
event id (str)
"""
- return user.username + '.' + block_key + '.' + date_type
+ return '{}.{}.{}@{}'.format(user.id, block_key, date_type, hostname)
diff --git a/openedx/features/calendar_sync/ics.py b/openedx/features/calendar_sync/ics.py
new file mode 100644
index 0000000000..d36078181c
--- /dev/null
+++ b/openedx/features/calendar_sync/ics.py
@@ -0,0 +1,73 @@
+""" Generate .ics files from a user schedule """
+
+from datetime import datetime, timedelta
+
+import pytz
+from django.conf import settings
+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.djangolib.markup import HTML
+
+from . import get_calendar_event_id
+
+
+def generate_ics_for_event(uid, summary, url, now, start, organizer_name, organizer_email):
+ """
+ Generates an ics-formatted bytestring for the given assignment information.
+
+ To pretty-print the bytestring, do: `ics.decode('utf8').replace('\r\n', '\n')`
+ """
+ # icalendar library: https://icalendar.readthedocs.io/en/latest/
+ # ics format spec: https://tools.ietf.org/html/rfc2445
+ # ics conventions spec: https://tools.ietf.org/html/rfc5546
+
+ organizer = vCalAddress('mailto:' + organizer_email)
+ organizer.params['cn'] = vText(organizer_name)
+
+ event = Event()
+ event.add('uid', uid)
+ event.add('dtstamp', now)
+ event.add('organizer', organizer, encode=0)
+ event.add('summary', summary)
+ # FIXME description should be translated if we use hardcoded text, once we finalize that text
+ event.add('description', HTML('Link').format(url=url))
+ event.add('dtstart', start)
+ event.add('duration', timedelta(0))
+ event.add('transp', 'TRANSPARENT') # available, rather than busy
+
+ cal = Calendar()
+ cal.add('prodid', '-//Open edX//calendar_sync//EN')
+ cal.add('version', '2.0')
+ cal.add('method', 'REQUEST')
+ cal.add_component(event)
+
+ return cal.to_ical()
+
+
+def generate_ics_for_user_course(course_key, user, request):
+ """
+ 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.
+ """
+ assignments = get_course_assignments(course_key, user, request)
+ 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)
+
+ return (
+ generate_ics_for_event(
+ now=now,
+ organizer_name=platform_name,
+ organizer_email=platform_email,
+ start=assignment.date,
+ summary=assignment.title,
+ uid=get_calendar_event_id(user, str(assignment.block_key), 'due', request.site.domain),
+ url=assignment.url,
+ )
+ for assignment in assignments
+ )
diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py
new file mode 100644
index 0000000000..9d70b38ea2
--- /dev/null
+++ b/openedx/features/calendar_sync/tests/test_ics.py
@@ -0,0 +1,94 @@
+""" Tests for the Calendar Sync .ics methods """
+
+from datetime import datetime, timedelta
+
+import pytz
+from django.test import RequestFactory, TestCase
+from freezegun import freeze_time
+from mock import patch
+
+from lms.djangoapps.courseware.courses import _Assignment
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
+from openedx.features.calendar_sync import get_calendar_event_id
+from openedx.features.calendar_sync.ics import generate_ics_for_user_course
+from student.tests.factories import UserFactory
+
+
+class TestIcsGeneration(TestCase):
+ """ Test icalendar file generator """
+ def setUp(self):
+ super().setUp()
+
+ freezer = freeze_time(datetime(2013, 10, 3, 8, 24, 55, tzinfo=pytz.utc))
+ self.addCleanup(freezer.stop)
+ freezer.start()
+
+ self.user = UserFactory()
+ self.request = RequestFactory().request()
+ self.request.site = SiteFactory()
+ self.request.user = self.user
+
+ def make_assigment(self, block_key=None, title=None, url=None, date=None, requires_file_access=False):
+ """ Bundles given info into a namedtupled like get_course_assignments returns """
+ return _Assignment(block_key, title, url, date, requires_file_access)
+
+ def expected_ics(self, *assignments):
+ """ Returns hardcoded expected ics strings for given assignments """
+ template = '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Open edX//calendar_sync//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+SUMMARY:{summary}
+DTSTART;VALUE=DATE-TIME:{timedue}
+DURATION:P0D
+DTSTAMP;VALUE=DATE-TIME:20131003T082455Z
+UID:{uid}
+DESCRIPTION:Link
+ORGANIZER;CN=édX:mailto:registration@example.com
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+'''
+ return (
+ template.format(
+ summary=assignment.title,
+ timedue=assignment.date.strftime('%Y%m%dT%H%M%SZ'),
+ url=assignment.url,
+ uid=get_calendar_event_id(self.user, str(assignment.block_key), 'due', self.request.site.domain),
+ )
+ for assignment in assignments
+ )
+
+ def generate_ics(self, *assignments):
+ """ 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('a/b/c', self.user, self.request)
+
+ 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)]
+ self.assertEqual(len(generated), len(assignments))
+ self.assertListEqual(generated, list(self.expected_ics(*assignments)))
+
+ def test_generate_ics_for_user_course(self):
+ """ Tests that a simple sample set of course assignments is generated correctly """
+ now = datetime.now(pytz.utc)
+ day1 = now + timedelta(1)
+ day2 = now + timedelta(1)
+
+ self.assert_ics(
+ self.make_assigment(
+ block_key='block1',
+ title='Block1',
+ url='https://example.com/block1',
+ date=day1,
+ ),
+ self.make_assigment(
+ block_key='block2',
+ title='Block2',
+ url='https://example.com/block2',
+ date=day2,
+ ),
+ )
diff --git a/openedx/features/calendar_sync/tests/test_init.py b/openedx/features/calendar_sync/tests/test_init.py
index 79e2584ff1..6722abfb9e 100644
--- a/openedx/features/calendar_sync/tests/test_init.py
+++ b/openedx/features/calendar_sync/tests/test_init.py
@@ -18,8 +18,9 @@ class TestCalendarSyncInit(TestCase):
def test_get_calendar_event_id(self):
block_key = 'block-v1:Org+Number+Term+type@sequential+block@gibberish'
date_type = 'due'
- event_id = get_calendar_event_id(self.user, block_key, date_type)
- expected = '{username}.{block_key}.{date_type}'.format(
- username=self.user.username, block_key=block_key, date_type=date_type
+ hostname = 'example.com'
+ event_id = get_calendar_event_id(self.user, block_key, date_type, hostname)
+ expected = '{user_id}.{block_key}.{date_type}@{hostname}'.format(
+ user_id=self.user.id, block_key=block_key, date_type=date_type, hostname=hostname
)
self.assertEqual(event_id, expected)
diff --git a/requirements/edx/base.in b/requirements/edx/base.in
index fba36eff39..8cc6ae1b8b 100644
--- a/requirements/edx/base.in
+++ b/requirements/edx/base.in
@@ -94,6 +94,7 @@ glob2 # Enhanced glob module, used in openedx.core
gunicorn
help-tokens
html5lib # HTML parser, used for capa problems
+icalendar # .ics generator, used by calendar_sync
ipaddress # Ip network support for Embargo feature
jsonfield2 # Django model field for validated JSON; used in several apps
laboratory # Library for testing that code refactors/infrastructure changes produce identical results
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 8059ce099c..de70a90c5a 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -130,6 +130,7 @@ gunicorn==20.0.4 # via -r requirements/edx/base.in
help-tokens==1.0.5 # via -r requirements/edx/base.in
html5lib==1.0.1 # via -r requirements/edx/base.in, ora2
httplib2==0.17.0 # via oauth2
+icalendar==4.0.4 # via -r requirements/edx/base.in
idna==2.9 # via -r requirements/edx/paver.txt, requests
importlib-metadata==1.5.0 # via -r requirements/edx/paver.txt, path
inflection==0.3.1 # via drf-yasg
@@ -193,14 +194,14 @@ pymongo==3.9.0 # via -r requirements/edx/base.in, -r requirements/edx
pynliner==0.8.0 # via -r requirements/edx/base.in
pyparsing==2.2.0 # via chem, openedx-calc, packaging, pycontracts
pysrt==1.1.2 # via -r requirements/edx/base.in, edxval
-python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, ora2, xblock
+python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, icalendar, ora2, xblock
python-levenshtein==0.12.0 # via -r requirements/edx/base.in
python-memcached==1.59 # via -r requirements/edx/paver.txt
python-slugify==4.0.0 # via code-annotations
python-swiftclient==3.9.0 # via ora2
python3-openid==3.1.0 ; python_version >= "3" # via -r requirements/edx/base.in, social-auth-core
python3-saml==1.5.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in
-pytz==2019.3 # via -r requirements/edx/base.in, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, xblock
+pytz==2019.3 # via -r requirements/edx/base.in, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, xblock
pyuca==1.2 # via -r requirements/edx/base.in
pyyaml==5.3 # via -r requirements/edx/base.in, code-annotations, edx-django-release-util, edx-i18n-tools, xblock
random2==1.0.1 # via -r requirements/edx/base.in
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 3eeb083ab2..c124b0b55c 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -153,6 +153,7 @@ help-tokens==1.0.5 # via -r requirements/edx/testing.txt
html5lib==1.0.1 # via -r requirements/edx/testing.txt, ora2
httplib2==0.17.0 # via -r requirements/edx/testing.txt, oauth2
httpretty==0.9.7 # via -r requirements/edx/testing.txt
+icalendar==4.0.4 # via -r requirements/edx/testing.txt
idna==2.9 # via -r requirements/edx/testing.txt, requests
imagesize==1.2.0 # via sphinx
importlib-metadata==1.5.0 # via -r requirements/edx/testing.txt, importlib-resources, inflect, jsonschema, path, pluggy, pytest, pytest-randomly, tox, virtualenv
@@ -250,14 +251,14 @@ pytest-metadata==1.8.0 # via -r requirements/edx/testing.txt, pytest-json-rep
pytest-randomly==3.2.1 # via -r requirements/edx/testing.txt
pytest-xdist==1.31.0 # via -r requirements/edx/testing.txt
pytest==5.4.1 # via -r requirements/edx/testing.txt, pytest-attrib, pytest-cov, pytest-django, pytest-forked, pytest-json-report, pytest-metadata, pytest-randomly, pytest-xdist
-python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, ora2, pandas, xblock
+python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, icalendar, ora2, pandas, xblock
python-levenshtein==0.12.0 # via -r requirements/edx/testing.txt
python-memcached==1.59 # via -r requirements/edx/testing.txt
python-slugify==4.0.0 # via -r requirements/edx/testing.txt, code-annotations, transifex-client
python-swiftclient==3.9.0 # via -r requirements/edx/testing.txt, ora2
python3-openid==3.1.0 ; python_version >= "3" # via -r requirements/edx/testing.txt, social-auth-core
python3-saml==1.5.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt
-pytz==2019.3 # via -r requirements/edx/testing.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, pandas, xblock
+pytz==2019.3 # via -r requirements/edx/testing.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, pandas, xblock
pyuca==1.2 # via -r requirements/edx/testing.txt
pyyaml==5.3 # via -r requirements/edx/testing.txt, code-annotations, edx-django-release-util, edx-i18n-tools, sphinxcontrib-openapi, xblock
radon==4.1.0 # via -r requirements/edx/testing.txt
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 1dcd7e108a..e8bee9b447 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -148,6 +148,7 @@ help-tokens==1.0.5 # via -r requirements/edx/base.txt
html5lib==1.0.1 # via -r requirements/edx/base.txt, ora2
httplib2==0.17.0 # via -r requirements/edx/base.txt, oauth2
httpretty==0.9.7 # via -r requirements/edx/testing.in
+icalendar==4.0.4 # via -r requirements/edx/base.txt
idna==2.9 # via -r requirements/edx/base.txt, requests
importlib-metadata==1.5.0 # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, importlib-resources, inflect, path, pluggy, pytest, pytest-randomly, tox, virtualenv
importlib-resources==1.3.1 # via virtualenv
@@ -238,14 +239,14 @@ pytest-metadata==1.8.0 # via pytest-json-report
pytest-randomly==3.2.1 # via -r requirements/edx/testing.in
pytest-xdist==1.31.0 # via -r requirements/edx/testing.in
pytest==5.4.1 # via -r requirements/edx/testing.in, pytest-attrib, pytest-cov, pytest-django, pytest-forked, pytest-json-report, pytest-metadata, pytest-randomly, pytest-xdist
-python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, ora2, pandas, xblock
+python-dateutil==2.4.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, analytics-python, botocore, edx-ace, edx-drf-extensions, edx-enterprise, edx-proctoring, faker, freezegun, icalendar, ora2, pandas, xblock
python-levenshtein==0.12.0 # via -r requirements/edx/base.txt
python-memcached==1.59 # via -r requirements/edx/base.txt
python-slugify==4.0.0 # via -r requirements/edx/base.txt, code-annotations, transifex-client
python-swiftclient==3.9.0 # via -r requirements/edx/base.txt, ora2
python3-openid==3.1.0 ; python_version >= "3" # via -r requirements/edx/base.txt, social-auth-core
python3-saml==1.5.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt
-pytz==2019.3 # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, ora2, pandas, xblock
+pytz==2019.3 # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, babel, capa, celery, django, django-ses, edx-completion, edx-enterprise, edx-proctoring, edx-submissions, edx-tincan-py35, event-tracking, fs, icalendar, ora2, pandas, xblock
pyuca==1.2 # via -r requirements/edx/base.txt
pyyaml==5.3 # via -r requirements/edx/base.txt, code-annotations, edx-django-release-util, edx-i18n-tools, xblock
radon==4.1.0 # via -r requirements/edx/testing.in