From bb36ab013f976b4e5b3dad12171f730dd22fc434 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia <gdimilia@mit.edu> Date: Tue, 1 Dec 2015 11:55:47 -0500 Subject: [PATCH] Added CCXCon app The CCXCon app is used to push course updated to the CCXCon externale service. --- cms/djangoapps/models/settings/course_metadata.py | 1 + cms/envs/aws.py | 4 ++++ cms/envs/test.py | 4 ++++ cms/envs/yaml_config.py | 4 ++++ common/lib/xmodule/xmodule/course_module.py | 10 ++++++++++ lms/envs/aws.py | 2 +- lms/envs/test.py | 2 +- lms/envs/yaml_config.py | 2 +- openedx/core/djangoapps/ccxcon/__init__.py | 9 +++++++++ openedx/core/djangoapps/ccxcon/admin.py | 9 +++++++++ openedx/core/djangoapps/ccxcon/api.py | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/migrations/0001_initial_ccxcon_model.py | 27 +++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/migrations/__init__.py | 0 openedx/core/djangoapps/ccxcon/models.py | 31 +++++++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/signals.py | 18 ++++++++++++++++++ openedx/core/djangoapps/ccxcon/tasks.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/tests/__init__.py | 0 openedx/core/djangoapps/ccxcon/tests/factories.py | 17 +++++++++++++++++ openedx/core/djangoapps/ccxcon/tests/test_api.py | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/tests/test_signals.py | 34 ++++++++++++++++++++++++++++++++++ openedx/core/djangoapps/ccxcon/tests/test_tasks.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 21 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 openedx/core/djangoapps/ccxcon/__init__.py create mode 100644 openedx/core/djangoapps/ccxcon/admin.py create mode 100644 openedx/core/djangoapps/ccxcon/api.py create mode 100644 openedx/core/djangoapps/ccxcon/migrations/0001_initial_ccxcon_model.py create mode 100644 openedx/core/djangoapps/ccxcon/migrations/__init__.py create mode 100644 openedx/core/djangoapps/ccxcon/models.py create mode 100644 openedx/core/djangoapps/ccxcon/signals.py create mode 100644 openedx/core/djangoapps/ccxcon/tasks.py create mode 100644 openedx/core/djangoapps/ccxcon/tests/__init__.py create mode 100644 openedx/core/djangoapps/ccxcon/tests/factories.py create mode 100644 openedx/core/djangoapps/ccxcon/tests/test_api.py create mode 100644 openedx/core/djangoapps/ccxcon/tests/test_signals.py create mode 100644 openedx/core/djangoapps/ccxcon/tests/test_tasks.py diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d0af451..176669f 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -92,6 +92,7 @@ class CourseMetadata(object): # Do not show enable_ccx if feature is not enabled. if not settings.FEATURES.get('CUSTOM_COURSES_EDX'): filtered_list.append('enable_ccx') + filtered_list.append('ccx_connector') return filtered_list diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b1dcb64..5311e7d 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -379,3 +379,7 @@ MICROSITE_DATABASE_TEMPLATE_CACHE_TTL = ENV_TOKENS.get( # OpenID Connect issuer ID. Normally the URL of the authentication endpoint. 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',) diff --git a/cms/envs/test.py b/cms/envs/test.py index 89c4586..8577e82 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -313,3 +313,7 @@ FEATURES['ENABLE_TEAMS'] = True # Dummy secret key for dev/test SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' + +######### custom courses ######### +INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',) +FEATURES['CUSTOM_COURSES_EDX'] = True diff --git a/cms/envs/yaml_config.py b/cms/envs/yaml_config.py index 7b19b35..96bd8cf 100644 --- a/cms/envs/yaml_config.py +++ b/cms/envs/yaml_config.py @@ -247,3 +247,7 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT, CELERY_BROKER_PASSWORD, CELERY_BROKER_HOSTNAME, CELERY_BROKER_VHOST) + +######################## CUSTOM COURSES for EDX CONNECTOR ###################### +if FEATURES.get('CUSTOM_COURSES_EDX'): + INSTALLED_APPS += ('openedx.core.djangoapps.ccxcon',) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 49d9554..ca89541 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -395,6 +395,16 @@ class CourseFields(object): default=False, 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( display_name=_("Allow Anonymous Discussion Posts"), help=_("Enter true or false. If true, students can create discussion posts that are anonymous to all users."), diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 3510173..a652977 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -675,7 +675,7 @@ ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TI ##### Custom Courses for EdX ##### if FEATURES.get('CUSTOM_COURSES_EDX'): - INSTALLED_APPS += ('lms.djangoapps.ccx',) + INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon') FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider', ) diff --git a/lms/envs/test.py b/lms/envs/test.py index 2b58e02..4ff6fd3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -535,7 +535,7 @@ FACEBOOK_APP_ID = "Test" FACEBOOK_API_VERSION = "v2.2" ######### custom courses ######### -INSTALLED_APPS += ('lms.djangoapps.ccx',) +INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon') FEATURES['CUSTOM_COURSES_EDX'] = True # Set dummy values for profile image settings. diff --git a/lms/envs/yaml_config.py b/lms/envs/yaml_config.py index fbb01af..5b8fbdf 100644 --- a/lms/envs/yaml_config.py +++ b/lms/envs/yaml_config.py @@ -298,7 +298,7 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE ##### Custom Courses for EdX ##### if FEATURES.get('CUSTOM_COURSES_EDX'): - INSTALLED_APPS += ('lms.djangoapps.ccx',) + INSTALLED_APPS += ('lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon') FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider', ) diff --git a/openedx/core/djangoapps/ccxcon/__init__.py b/openedx/core/djangoapps/ccxcon/__init__.py new file mode 100644 index 0000000..6c2cbb1 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/__init__.py @@ -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 diff --git a/openedx/core/djangoapps/ccxcon/admin.py b/openedx/core/djangoapps/ccxcon/admin.py new file mode 100644 index 0000000..41d38a8 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/admin.py @@ -0,0 +1,9 @@ +""" +Admin site bindings for ccxcon +""" + +from django.contrib import admin + +from .models import CCXCon + +admin.site.register(CCXCon) diff --git a/openedx/core/djangoapps/ccxcon/api.py b/openedx/core/djangoapps/ccxcon/api.py new file mode 100644 index 0000000..e8b6fbc --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/api.py @@ -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) diff --git a/openedx/core/djangoapps/ccxcon/migrations/0001_initial_ccxcon_model.py b/openedx/core/djangoapps/ccxcon/migrations/0001_initial_ccxcon_model.py new file mode 100644 index 0000000..80180dc --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/migrations/0001_initial_ccxcon_model.py @@ -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)), + ], + ), + ] diff --git a/openedx/core/djangoapps/ccxcon/migrations/__init__.py b/openedx/core/djangoapps/ccxcon/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/migrations/__init__.py diff --git a/openedx/core/djangoapps/ccxcon/models.py b/openedx/core/djangoapps/ccxcon/models.py new file mode 100644 index 0000000..bac4b25 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/models.py @@ -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__()) diff --git a/openedx/core/djangoapps/ccxcon/signals.py b/openedx/core/djangoapps/ccxcon/signals.py new file mode 100644 index 0000000..71804a4 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/signals.py @@ -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)) diff --git a/openedx/core/djangoapps/ccxcon/tasks.py b/openedx/core/djangoapps/ccxcon/tasks.py new file mode 100644 index 0000000..642afc8 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tasks.py @@ -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) diff --git a/openedx/core/djangoapps/ccxcon/tests/__init__.py b/openedx/core/djangoapps/ccxcon/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tests/__init__.py diff --git a/openedx/core/djangoapps/ccxcon/tests/factories.py b/openedx/core/djangoapps/ccxcon/tests/factories.py new file mode 100644 index 0000000..5b7db65 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tests/factories.py @@ -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' diff --git a/openedx/core/djangoapps/ccxcon/tests/test_api.py b/openedx/core/djangoapps/ccxcon/tests/test_api.py new file mode 100644 index 0000000..29abd04 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tests/test_api.py @@ -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)) diff --git a/openedx/core/djangoapps/ccxcon/tests/test_signals.py b/openedx/core/djangoapps/ccxcon/tests/test_signals.py new file mode 100644 index 0000000..559a28e --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tests/test_signals.py @@ -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) diff --git a/openedx/core/djangoapps/ccxcon/tests/test_tasks.py b/openedx/core/djangoapps/ccxcon/tests/test_tasks.py new file mode 100644 index 0000000..68d54c3 --- /dev/null +++ b/openedx/core/djangoapps/ccxcon/tests/test_tasks.py @@ -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) -- libgit2 0.26.0