diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index d67c4e6c6b82a05bfe9d19e5b9ae8560c5efc8c2..ba1fe06e72e9bdf3301dc3f7e6a9d96ca60f9be1 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 f83d0001c51004ef20ed170d796acc8cb0e1ddd8..77fd2dd0b26ef7543f63d52b464fd5906d21f7e5 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 0000000000000000000000000000000000000000..d36078181cf4fad639999a2e5467eb0ddd670887 --- /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('<a href="{url}">Link</a>').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 0000000000000000000000000000000000000000..9d70b38ea2e0d4c61b17e47bb4a996ab57ea80e1 --- /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:<a href="{url}">Link</a> +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 79e2584ff175cafa899a8bcf356395192bb10314..6722abfb9e0655f75bcac41e1d6e7016e64b1018 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 fba36eff396a550614d85b99aa064b6965998c22..8cc6ae1b8b488b19d8e50e9dabb18db5d98acaee 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 8059ce099c2bdf278e97f2c58740a732f34bf31c..de70a90c5ab0c9bd339ffca4ee1bb6a1779087af 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 3eeb083ab2b685d256c8cc3afa45b134c279bc37..c124b0b55c5a9eae986a1e21d13011ef2f339dd3 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 1dcd7e108afb3d51d83f4297cbb464637f3297d0..e8bee9b447afea5a2d3914f524f50e2390c63441 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