Added CCXCon app
The CCXCon app is used to push course updated to the CCXCon externale service.
This commit is contained in:
@@ -92,6 +92,7 @@ class CourseMetadata(object):
|
|||||||
# Do not show enable_ccx if feature is not enabled.
|
# Do not show enable_ccx if feature is not enabled.
|
||||||
if not settings.FEATURES.get('CUSTOM_COURSES_EDX'):
|
if not settings.FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||||
filtered_list.append('enable_ccx')
|
filtered_list.append('enable_ccx')
|
||||||
|
filtered_list.append('ccx_connector')
|
||||||
|
|
||||||
return filtered_list
|
return filtered_list
|
||||||
|
|
||||||
|
|||||||
@@ -379,3 +379,7 @@ MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = ENV_TOKENS.get(
|
|||||||
|
|
||||||
# OpenID Connect issuer ID. Normally the URL of the authentication endpoint.
|
# OpenID Connect issuer ID. Normally the URL of the authentication endpoint.
|
||||||
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
|
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
|
||||||
|
|
||||||
|
######################## CUSTOM COURSES for EDX CONNECTOR ######################
|
||||||
|
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||||
|
INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',)
|
||||||
|
|||||||
@@ -313,3 +313,7 @@ FEATURES['ENABLE_TEAMS'] = True
|
|||||||
|
|
||||||
# Dummy secret key for dev/test
|
# Dummy secret key for dev/test
|
||||||
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||||
|
|
||||||
|
######### custom courses #########
|
||||||
|
INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',)
|
||||||
|
FEATURES['CUSTOM_COURSES_EDX'] = True
|
||||||
|
|||||||
@@ -247,3 +247,7 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
|
|||||||
CELERY_BROKER_PASSWORD,
|
CELERY_BROKER_PASSWORD,
|
||||||
CELERY_BROKER_HOSTNAME,
|
CELERY_BROKER_HOSTNAME,
|
||||||
CELERY_BROKER_VHOST)
|
CELERY_BROKER_VHOST)
|
||||||
|
|
||||||
|
######################## CUSTOM COURSES for EDX CONNECTOR ######################
|
||||||
|
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||||
|
INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',)
|
||||||
|
|||||||
@@ -395,6 +395,16 @@ class CourseFields(object):
|
|||||||
default=False,
|
default=False,
|
||||||
scope=Scope.settings
|
scope=Scope.settings
|
||||||
)
|
)
|
||||||
|
ccx_connector = String(
|
||||||
|
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
|
||||||
|
display_name=_("CCX Connector URL"),
|
||||||
|
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
|
||||||
|
help=_(
|
||||||
|
"URL for CCX Connector application for managing creation of CCXs. (optional)."
|
||||||
|
" Ignored unless 'Enable CCX' is set to 'true'."
|
||||||
|
),
|
||||||
|
scope=Scope.settings, default=""
|
||||||
|
)
|
||||||
allow_anonymous = Boolean(
|
allow_anonymous = Boolean(
|
||||||
display_name=_("Allow Anonymous Discussion Posts"),
|
display_name=_("Allow Anonymous Discussion Posts"),
|
||||||
help=_("Enter true or false. If true, students can create discussion posts that are anonymous to all users."),
|
help=_("Enter true or false. If true, students can create discussion posts that are anonymous to all users."),
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TI
|
|||||||
|
|
||||||
##### Custom Courses for EdX #####
|
##### Custom Courses for EdX #####
|
||||||
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||||
INSTALLED_APPS += ('lms.djangoapps.ccx',)
|
INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon')
|
||||||
FIELD_OVERRIDE_PROVIDERS += (
|
FIELD_OVERRIDE_PROVIDERS += (
|
||||||
'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider',
|
'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ FACEBOOK_APP_ID = "Test"
|
|||||||
FACEBOOK_API_VERSION = "v2.2"
|
FACEBOOK_API_VERSION = "v2.2"
|
||||||
|
|
||||||
######### custom courses #########
|
######### custom courses #########
|
||||||
INSTALLED_APPS += ('lms.djangoapps.ccx',)
|
INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon')
|
||||||
FEATURES['CUSTOM_COURSES_EDX'] = True
|
FEATURES['CUSTOM_COURSES_EDX'] = True
|
||||||
|
|
||||||
# Set dummy values for profile image settings.
|
# Set dummy values for profile image settings.
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
|
|||||||
|
|
||||||
##### Custom Courses for EdX #####
|
##### Custom Courses for EdX #####
|
||||||
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||||
INSTALLED_APPS += ('lms.djangoapps.ccx',)
|
INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon')
|
||||||
FIELD_OVERRIDE_PROVIDERS += (
|
FIELD_OVERRIDE_PROVIDERS += (
|
||||||
'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider',
|
'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider',
|
||||||
)
|
)
|
||||||
|
|||||||
9
openedx/core/djangoapps/ccxcon/__init__.py
Normal file
9
openedx/core/djangoapps/ccxcon/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
The ccxcon app contains the models and the APIs to interact
|
||||||
|
with the `CCX Connector`, an application external to openedx
|
||||||
|
that is used to interact with the CCX and their master courses.
|
||||||
|
|
||||||
|
The ccxcon app needs to be placed in `openedx.core.djangoapps`
|
||||||
|
because it will be used both in CMS and LMS.
|
||||||
|
"""
|
||||||
|
import openedx.core.djangoapps.ccxcon.signals
|
||||||
9
openedx/core/djangoapps/ccxcon/admin.py
Normal file
9
openedx/core/djangoapps/ccxcon/admin.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Admin site bindings for ccxcon
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import CCXCon
|
||||||
|
|
||||||
|
admin.site.register(CCXCon)
|
||||||
159
openedx/core/djangoapps/ccxcon/api.py
Normal file
159
openedx/core/djangoapps/ccxcon/api.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Module containing API functions for the CCXCon
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import urlparse
|
||||||
|
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.http import Http404
|
||||||
|
from oauthlib.oauth2 import BackendApplicationClient
|
||||||
|
from requests_oauthlib import OAuth2Session
|
||||||
|
from rest_framework.status import (
|
||||||
|
HTTP_200_OK,
|
||||||
|
HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lms.djangoapps.courseware.courses import get_course_by_id
|
||||||
|
from lms.djangoapps.instructor.access import list_with_level
|
||||||
|
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||||
|
from student.models import anonymous_id_for_user
|
||||||
|
from .models import CCXCon
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
CCXCON_COURSEXS_URL = '/api/v1/coursexs/'
|
||||||
|
CCXCON_TOKEN_URL = '/o/token/'
|
||||||
|
CCXCON_REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
class CCXConnServerError(Exception):
|
||||||
|
"""
|
||||||
|
Custom exception to be raised in case there is any
|
||||||
|
issue with the request to the server
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_url(url):
|
||||||
|
"""
|
||||||
|
Helper function used to check if a string is a valid url.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): the url string to be validated
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: whether the url is valid or not
|
||||||
|
"""
|
||||||
|
validate = URLValidator()
|
||||||
|
try:
|
||||||
|
validate(url)
|
||||||
|
return True
|
||||||
|
except ValidationError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_client(server_token_url, client_id, client_secret):
|
||||||
|
"""
|
||||||
|
Function that creates an oauth client and fetches a token.
|
||||||
|
It intentionally doesn't handle errors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_token_url (str): server URL where to get an authentication token
|
||||||
|
client_id (str): oauth client ID
|
||||||
|
client_secret (str): oauth client secret
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OAuth2Session: an instance of OAuth2Session with a token
|
||||||
|
"""
|
||||||
|
if not is_valid_url(server_token_url):
|
||||||
|
return
|
||||||
|
client = BackendApplicationClient(client_id=client_id)
|
||||||
|
oauth_ccxcon = OAuth2Session(client=client)
|
||||||
|
oauth_ccxcon.fetch_token(
|
||||||
|
token_url=server_token_url,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
timeout=CCXCON_REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
return oauth_ccxcon
|
||||||
|
|
||||||
|
|
||||||
|
def course_info_to_ccxcon(course_key):
|
||||||
|
"""
|
||||||
|
Function that gathers informations about the course and
|
||||||
|
makes a post request to a CCXCon with the data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_key (CourseLocator): the master course key
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
course = get_course_by_id(course_key)
|
||||||
|
except Http404:
|
||||||
|
log.error('Master Course with key "%s" not found', unicode(course_key))
|
||||||
|
return
|
||||||
|
if not course.enable_ccx:
|
||||||
|
log.debug('ccx not enabled for course key "%s"', unicode(course_key))
|
||||||
|
return
|
||||||
|
if not course.ccx_connector:
|
||||||
|
log.debug('ccx connector not defined for course key "%s"', unicode(course_key))
|
||||||
|
return
|
||||||
|
if not is_valid_url(course.ccx_connector):
|
||||||
|
log.error(
|
||||||
|
'ccx connector URL "%s" for course key "%s" is not a valid URL.',
|
||||||
|
course.ccx_connector, unicode(course_key)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# get the oauth credential for this URL
|
||||||
|
try:
|
||||||
|
ccxcon = CCXCon.objects.get(url=course.ccx_connector)
|
||||||
|
except CCXCon.DoesNotExist:
|
||||||
|
log.error('ccx connector Oauth credentials not configured for URL "%s".', course.ccx_connector)
|
||||||
|
return
|
||||||
|
|
||||||
|
# get an oauth client with a valid token
|
||||||
|
|
||||||
|
oauth_ccxcon = get_oauth_client(
|
||||||
|
server_token_url=urlparse.urljoin(course.ccx_connector, CCXCON_TOKEN_URL),
|
||||||
|
client_id=ccxcon.oauth_client_id,
|
||||||
|
client_secret=ccxcon.oauth_client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
# get the entire list of instructors
|
||||||
|
course_instructors = list_with_level(course, 'instructor')
|
||||||
|
# get anonymous ids for each of them
|
||||||
|
course_instructors_ids = [anonymous_id_for_user(user, course_key) for user in course_instructors]
|
||||||
|
# extract the course details
|
||||||
|
course_details = CourseDetails.fetch(course_key)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'course_id': unicode(course_key),
|
||||||
|
'title': course.display_name,
|
||||||
|
'author_name': None,
|
||||||
|
'overview': course_details.overview,
|
||||||
|
'description': course_details.short_description,
|
||||||
|
'image_url': course_details.course_image_asset_path,
|
||||||
|
'instructors': course_instructors_ids
|
||||||
|
}
|
||||||
|
headers = {'content-type': 'application/json'}
|
||||||
|
|
||||||
|
# make the POST request
|
||||||
|
add_course_url = urlparse.urljoin(course.ccx_connector, CCXCON_COURSEXS_URL)
|
||||||
|
resp = oauth_ccxcon.post(
|
||||||
|
url=add_course_url,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=CCXCON_REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
raise CCXConnServerError('Server returned error Status: %s, Content: %s', resp.status_code, resp.content)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
log.error("Error creating course on ccxcon. Status: %s, Content: %s", resp.status_code, resp.content)
|
||||||
|
# this API performs a POST request both for POST and PATCH, but the POST returns 201 and the PATCH returns 200
|
||||||
|
elif resp.status_code != HTTP_200_OK and resp.status_code != HTTP_201_CREATED:
|
||||||
|
log.error('Server returned unexpected status code %s', resp.status_code)
|
||||||
|
else:
|
||||||
|
log.debug('Request successful. Status: %s, Content: %s', resp.status_code, resp.content)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Initial migration
|
||||||
|
"""
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
Initial migration for CCXCon model
|
||||||
|
"""
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CCXCon',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
|
('url', models.URLField(unique=True, db_index=True)),
|
||||||
|
('oauth_client_id', models.CharField(max_length=255)),
|
||||||
|
('oauth_client_secret', models.CharField(max_length=255)),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
31
openedx/core/djangoapps/ccxcon/models.py
Normal file
31
openedx/core/djangoapps/ccxcon/models.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Models for the ccxcon
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class CCXCon(models.Model):
|
||||||
|
"""
|
||||||
|
The definition of the CCXCon model.
|
||||||
|
This will store the url and the oauth key to access the REST APIs
|
||||||
|
on the CCX Connector.
|
||||||
|
"""
|
||||||
|
url = models.URLField(unique=True, db_index=True)
|
||||||
|
oauth_client_id = models.CharField(max_length=255)
|
||||||
|
oauth_client_secret = models.CharField(max_length=255)
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
class Meta(object):
|
||||||
|
app_label = 'ccxcon'
|
||||||
|
verbose_name = 'CCX Connector'
|
||||||
|
verbose_name_plural = 'CCX Connectors'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<CCXCon {}>'.format(self.title)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return unicode(self.__str__())
|
||||||
18
openedx/core/djangoapps/ccxcon/signals.py
Normal file
18
openedx/core/djangoapps/ccxcon/signals.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Signal handler for posting course updated to CCXCon
|
||||||
|
"""
|
||||||
|
from django.dispatch.dispatcher import receiver
|
||||||
|
|
||||||
|
from xmodule.modulestore.django import SignalHandler
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(SignalHandler.course_published, dispatch_uid='ccxcon_course_publish_handler')
|
||||||
|
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||||
|
"""
|
||||||
|
Listener for course_plublish events.
|
||||||
|
This listener takes care of submitting a task to update CCXCon
|
||||||
|
"""
|
||||||
|
# update the course information on ccxcon using celery
|
||||||
|
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
|
||||||
|
from openedx.core.djangoapps.ccxcon import tasks
|
||||||
|
tasks.update_ccxcon.delay(unicode(course_key))
|
||||||
45
openedx/core/djangoapps/ccxcon/tasks.py
Normal file
45
openedx/core/djangoapps/ccxcon/tasks.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
This file contains celery tasks for ccxcon
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery.task import task # pylint: disable=no-name-in-module, import-error
|
||||||
|
from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
|
||||||
|
from requests.exceptions import (
|
||||||
|
ConnectionError,
|
||||||
|
HTTPError,
|
||||||
|
RequestException,
|
||||||
|
TooManyRedirects
|
||||||
|
)
|
||||||
|
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
|
||||||
|
from openedx.core.djangoapps.ccxcon import api
|
||||||
|
|
||||||
|
|
||||||
|
log = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task()
|
||||||
|
def update_ccxcon(course_id, cur_retry=0):
|
||||||
|
"""
|
||||||
|
Pass through function to update course information on CCXCon.
|
||||||
|
Takes care of retries in case of some specific exceptions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
course_id (str): string representing a course key
|
||||||
|
cur_retry (int): integer representing the current task retry
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
try:
|
||||||
|
api.course_info_to_ccxcon(course_key)
|
||||||
|
log.info('Course update to CCXCon returned no errors. Course key: %s', course_id)
|
||||||
|
except (ConnectionError, HTTPError, RequestException, TooManyRedirects, api.CCXConnServerError) as exp:
|
||||||
|
log.error('Course update to CCXCon failed for course_id %s with error: %s', course_id, exp)
|
||||||
|
# in case the maximum amount of retries has not been reached,
|
||||||
|
# insert another task delayed exponentially up to 5 retries
|
||||||
|
if cur_retry < 5:
|
||||||
|
update_ccxcon.apply_async(
|
||||||
|
kwargs={'course_id': course_id, 'cur_retry': cur_retry + 1},
|
||||||
|
countdown=10 ** cur_retry # number of seconds the task should be delayed
|
||||||
|
)
|
||||||
|
log.info('Requeued celery task for course key %s ; retry # %s', course_id, cur_retry + 1)
|
||||||
0
openedx/core/djangoapps/ccxcon/tests/__init__.py
Normal file
0
openedx/core/djangoapps/ccxcon/tests/__init__.py
Normal file
17
openedx/core/djangoapps/ccxcon/tests/factories.py
Normal file
17
openedx/core/djangoapps/ccxcon/tests/factories.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Dummy factories for tests
|
||||||
|
"""
|
||||||
|
from factory.django import DjangoModelFactory
|
||||||
|
from openedx.core.djangoapps.ccxcon.models import CCXCon
|
||||||
|
|
||||||
|
|
||||||
|
class CcxConFactory(DjangoModelFactory):
|
||||||
|
"""
|
||||||
|
Model factory for the CCXCon model
|
||||||
|
"""
|
||||||
|
class Meta(object):
|
||||||
|
model = CCXCon
|
||||||
|
|
||||||
|
oauth_client_id = 'asdfjasdljfasdkjffsdfjksd98fsd8y24fdsiuhsfdsf'
|
||||||
|
oauth_client_secret = '19123084091238901912308409123890'
|
||||||
|
title = 'title for test ccxcon'
|
||||||
214
openedx/core/djangoapps/ccxcon/tests/test_api.py
Normal file
214
openedx/core/djangoapps/ccxcon/tests/test_api.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the API module
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import mock
|
||||||
|
import pytz
|
||||||
|
import urlparse
|
||||||
|
from nose.plugins.attrib import attr
|
||||||
|
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
from student.tests.factories import AdminFactory
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
from xmodule.modulestore.tests.django_utils import (
|
||||||
|
SharedModuleStoreTestCase,
|
||||||
|
TEST_DATA_SPLIT_MODULESTORE
|
||||||
|
)
|
||||||
|
from xmodule.modulestore.tests.factories import (
|
||||||
|
CourseFactory,
|
||||||
|
ItemFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
from openedx.core.djangoapps.ccxcon import api as ccxconapi
|
||||||
|
from .factories import CcxConFactory
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(seq):
|
||||||
|
"""
|
||||||
|
For [[1, 2], [3, 4]] returns [1, 2, 3, 4]. Does not recurse.
|
||||||
|
"""
|
||||||
|
return [x for sub in seq for x in sub]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_token_mock(*args, **kwargs): # pylint: disable=unused-argument
|
||||||
|
"""
|
||||||
|
Mock function used to bypass the oauth fetch token
|
||||||
|
"""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@attr('shard_1')
|
||||||
|
class APIsTestCase(SharedModuleStoreTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for the API module functions
|
||||||
|
"""
|
||||||
|
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super(APIsTestCase, cls).setUpClass()
|
||||||
|
cls.course = course = CourseFactory.create()
|
||||||
|
cls.course_key = cls.course.location.course_key
|
||||||
|
|
||||||
|
# Create a course outline
|
||||||
|
start = datetime.datetime(
|
||||||
|
2010, 5, 12, 2, 42, tzinfo=pytz.UTC
|
||||||
|
)
|
||||||
|
due = datetime.datetime(
|
||||||
|
2010, 7, 7, 0, 0, tzinfo=pytz.UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.chapters = [
|
||||||
|
ItemFactory.create(start=start, parent=course) for _ in xrange(2)
|
||||||
|
]
|
||||||
|
cls.sequentials = flatten([
|
||||||
|
[
|
||||||
|
ItemFactory.create(parent=chapter) for _ in xrange(2)
|
||||||
|
] for chapter in cls.chapters
|
||||||
|
])
|
||||||
|
cls.verticals = flatten([
|
||||||
|
[
|
||||||
|
ItemFactory.create(
|
||||||
|
start=start, due=due, parent=sequential, graded=True, format='Homework', category=u'vertical'
|
||||||
|
) for _ in xrange(2)
|
||||||
|
] for sequential in cls.sequentials
|
||||||
|
])
|
||||||
|
|
||||||
|
# Trying to wrap the whole thing in a bulk operation fails because it
|
||||||
|
# doesn't find the parents. But we can at least wrap this part...
|
||||||
|
with cls.store.bulk_operations(course.id, emit_signals=False):
|
||||||
|
blocks = flatten([ # pylint: disable=unused-variable
|
||||||
|
[
|
||||||
|
ItemFactory.create(parent=vertical) for _ in xrange(2)
|
||||||
|
] for vertical in cls.verticals
|
||||||
|
])
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""
|
||||||
|
Set up tests
|
||||||
|
"""
|
||||||
|
super(APIsTestCase, self).setUp()
|
||||||
|
# Create instructor account
|
||||||
|
self.instructor = AdminFactory.create()
|
||||||
|
# create an instance of modulestore
|
||||||
|
self.mstore = modulestore()
|
||||||
|
# enable ccx
|
||||||
|
self.course.enable_ccx = True
|
||||||
|
# setup CCX connector
|
||||||
|
self.course.ccx_connector = 'https://url.to.cxx.connector.mit.edu'
|
||||||
|
# save the changes
|
||||||
|
self.mstore.update_item(self.course, self.instructor.id)
|
||||||
|
# create a configuration for the ccx connector: this must match the one in the course
|
||||||
|
self.ccxcon_conf = CcxConFactory(url=self.course.ccx_connector)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_no_valid_course_key(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for an invalid course key
|
||||||
|
"""
|
||||||
|
missing_course_key = CourseKey.from_string('course-v1:FakeOrganization+CN999+CR-FALL99')
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(missing_course_key))
|
||||||
|
self.assertEqual(mock_post.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_no_ccx_enabled(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for a course without CCX enabled
|
||||||
|
"""
|
||||||
|
self.course.enable_ccx = False
|
||||||
|
self.mstore.update_item(self.course, self.instructor.id)
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(self.course_key))
|
||||||
|
self.assertEqual(mock_post.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_invalid_ccx_connector(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for a course with invalid CCX connector URL
|
||||||
|
"""
|
||||||
|
# no connector at all
|
||||||
|
self.course.ccx_connector = ""
|
||||||
|
self.mstore.update_item(self.course, self.instructor.id)
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(self.course_key))
|
||||||
|
self.assertEqual(mock_post.call_count, 0)
|
||||||
|
# invalid url
|
||||||
|
self.course.ccx_connector = "www.foo"
|
||||||
|
self.mstore.update_item(self.course, self.instructor.id)
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(self.course_key))
|
||||||
|
self.assertEqual(mock_post.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_no_config(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for course with ccx connector credentials not configured
|
||||||
|
"""
|
||||||
|
self.course.ccx_connector = "https://www.foo.com"
|
||||||
|
self.mstore.update_item(self.course, self.instructor.id)
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(self.course_key))
|
||||||
|
self.assertEqual(mock_post.call_count, 0)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_ok(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for happy path
|
||||||
|
"""
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
ccxconapi.course_info_to_ccxcon(self.course_key)
|
||||||
|
|
||||||
|
self.assertEqual(mock_post.call_count, 1)
|
||||||
|
k_args, k_kwargs = mock_post.call_args
|
||||||
|
# no args used for the call
|
||||||
|
self.assertEqual(k_args, tuple())
|
||||||
|
self.assertEqual(
|
||||||
|
k_kwargs.get('url'),
|
||||||
|
urlparse.urljoin(self.course.ccx_connector, ccxconapi.CCXCON_COURSEXS_URL)
|
||||||
|
)
|
||||||
|
|
||||||
|
# second call with different status code
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
ccxconapi.course_info_to_ccxcon(self.course_key)
|
||||||
|
|
||||||
|
self.assertEqual(mock_post.call_count, 2)
|
||||||
|
k_args, k_kwargs = mock_post.call_args
|
||||||
|
# no args used for the call
|
||||||
|
self.assertEqual(k_args, tuple())
|
||||||
|
self.assertEqual(
|
||||||
|
k_kwargs.get('url'),
|
||||||
|
urlparse.urljoin(self.course.ccx_connector, ccxconapi.CCXCON_COURSEXS_URL)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_500_error(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for 500 error: a CCXConnServerError exception is raised
|
||||||
|
"""
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with self.assertRaises(ccxconapi.CCXConnServerError):
|
||||||
|
ccxconapi.course_info_to_ccxcon(self.course_key)
|
||||||
|
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.fetch_token', fetch_token_mock)
|
||||||
|
@mock.patch('requests_oauthlib.oauth2_session.OAuth2Session.post')
|
||||||
|
def test_course_info_to_ccxcon_other_status_codes(self, mock_post):
|
||||||
|
"""
|
||||||
|
Test for status codes different from >= 500 and 201:
|
||||||
|
The called function doesn't raise any exception and simply returns None.
|
||||||
|
"""
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
for status_code in (204, 300, 304, 400, 404):
|
||||||
|
mock_response.status_code = status_code
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
self.assertIsNone(ccxconapi.course_info_to_ccxcon(self.course_key))
|
||||||
34
openedx/core/djangoapps/ccxcon/tests/test_signals.py
Normal file
34
openedx/core/djangoapps/ccxcon/tests/test_signals.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Test for contentstore signals receiver
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
from xmodule.modulestore.django import modulestore, SignalHandler
|
||||||
|
|
||||||
|
|
||||||
|
class CCXConSignalTestCase(TestCase):
|
||||||
|
"""
|
||||||
|
The only tests currently implemented are for verifying that
|
||||||
|
the call for the ccxcon update are performed correctly by the
|
||||||
|
course_published signal handler
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mock.patch('openedx.core.djangoapps.ccxcon.tasks.update_ccxcon.delay')
|
||||||
|
def test_course_published_ccxcon_call(self, mock_upc):
|
||||||
|
"""
|
||||||
|
Tests the async call to the ccxcon task.
|
||||||
|
It bypasses all the other calls.
|
||||||
|
"""
|
||||||
|
mock_response = mock.MagicMock(return_value=None)
|
||||||
|
mock_upc.return_value = mock_response
|
||||||
|
|
||||||
|
course_id = u'course-v1:OrgFoo+CN199+CR-FALL01'
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
|
||||||
|
signal_handler = SignalHandler(modulestore())
|
||||||
|
signal_handler.send('course_published', course_key=course_key)
|
||||||
|
|
||||||
|
mock_upc.assert_called_once_with(course_id)
|
||||||
46
openedx/core/djangoapps/ccxcon/tests/test_tasks.py
Normal file
46
openedx/core/djangoapps/ccxcon/tests/test_tasks.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Tests for the CCXCon celery tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
from openedx.core.djangoapps.ccxcon import api, tasks
|
||||||
|
|
||||||
|
|
||||||
|
class CCXConTaskTestCase(TestCase):
|
||||||
|
"""
|
||||||
|
Tests for CCXCon tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mock.patch('openedx.core.djangoapps.ccxcon.api.course_info_to_ccxcon')
|
||||||
|
def test_update_ccxcon_task_ok(self, mock_citc):
|
||||||
|
"""
|
||||||
|
Test task with no problems
|
||||||
|
"""
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_citc.return_value = mock_response
|
||||||
|
|
||||||
|
course_id = u'course-v1:OrgFoo+CN199+CR-FALL01'
|
||||||
|
tasks.update_ccxcon.delay(course_id)
|
||||||
|
|
||||||
|
mock_citc.assert_called_once_with(CourseKey.from_string(course_id))
|
||||||
|
|
||||||
|
@mock.patch('openedx.core.djangoapps.ccxcon.api.course_info_to_ccxcon')
|
||||||
|
def test_update_ccxcon_task_retry(self, mock_citc):
|
||||||
|
"""
|
||||||
|
Test task with exception that triggers a retry
|
||||||
|
"""
|
||||||
|
mock_citc.side_effect = api.CCXConnServerError()
|
||||||
|
course_id = u'course-v1:OrgFoo+CN199+CR-FALL01'
|
||||||
|
tasks.update_ccxcon.delay(course_id)
|
||||||
|
|
||||||
|
self.assertEqual(mock_citc.call_count, 6)
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
for call in mock_citc.call_args_list:
|
||||||
|
c_args, c_kwargs = call
|
||||||
|
self.assertEqual(c_kwargs, {})
|
||||||
|
self.assertEqual(len(c_args), 1)
|
||||||
|
self.assertEqual(c_args[0], course_key)
|
||||||
Reference in New Issue
Block a user