Merge pull request #23404 from edx/mikix/icalendar
Add func to generate ics for schedule
This commit is contained in:
@@ -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)
|
||||
|
||||
73
openedx/features/calendar_sync/ics.py
Normal file
73
openedx/features/calendar_sync/ics.py
Normal file
@@ -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
|
||||
)
|
||||
94
openedx/features/calendar_sync/tests/test_ics.py
Normal file
94
openedx/features/calendar_sync/tests/test_ics.py
Normal file
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user