Skip to content
Snippets Groups Projects
Commit 4c241e57 authored by Michael Terry's avatar Michael Terry
Browse files

Add func to generate ics for schedule

This is an unused-as-of-yet utility function to generate a bunch
of ics files for a user's course schedule. Will be used as part
of the calendar_sync feature package.

AA-37
parent bcff9dbd
Branches
Tags
No related merge requests found
......@@ -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
......
......@@ -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)
""" 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
)
""" 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,
),
)
......@@ -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)
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment