diff --git a/lms/djangoapps/ccx/tasks.py b/lms/djangoapps/ccx/tasks.py new file mode 100644 index 0000000000..10c8492c83 --- /dev/null +++ b/lms/djangoapps/ccx/tasks.py @@ -0,0 +1,45 @@ +""" +Asynchronous tasks for the CCX app. +""" + +from django.dispatch import receiver +import logging + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import CourseLocator +from ccx_keys.locator import CCXLocator +from xmodule.modulestore.django import SignalHandler +from lms import CELERY_APP + +from .models import CustomCourseForEdX + +log = logging.getLogger("edx.ccx") + + +@receiver(SignalHandler.course_published) +def course_published_handler(sender, course_key, **kwargs): # pylint: disable=unused-argument + """ + Consume signals that indicate course published. If course already a CCX, do nothing. + """ + if not isinstance(course_key, CCXLocator): + send_ccx_course_published.delay(unicode(course_key)) + + +@CELERY_APP.task +def send_ccx_course_published(course_key): + """ + Find all CCX derived from this course, and send course published event for them. + """ + course_key = CourseLocator.from_string(course_key) + for ccx in CustomCourseForEdX.objects.filter(course_id=course_key): + try: + ccx_key = CCXLocator.from_course_locator(course_key, ccx.id) + except InvalidKeyError: + log.info('Attempt to publish course with deprecated id. Course: %s. CCX: %s', course_key, ccx.id) + continue + responses = SignalHandler.course_published.send( + sender=ccx, + course_key=ccx_key + ) + for rec, response in responses: + log.info('Signal fired when course is published. Receiver: %s. Response: %s', rec, response) diff --git a/lms/djangoapps/ccx/tests/test_tasks.py b/lms/djangoapps/ccx/tests/test_tasks.py new file mode 100644 index 0000000000..8af45ad5b5 --- /dev/null +++ b/lms/djangoapps/ccx/tests/test_tasks.py @@ -0,0 +1,110 @@ +""" +Tests for celery tasks defined in tasks module +""" + +from mock_django import mock_signal_receiver + +from ccx.tests.factories import ( # pylint: disable=import-error + CcxFactory, +) +from student.roles import CourseCcxCoachRole # pylint: disable=import-error +from student.tests.factories import ( # pylint: disable=import-error + AdminFactory, +) +from xmodule.modulestore.django import SignalHandler +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + TEST_DATA_SPLIT_MODULESTORE) +from openedx.core.djangoapps.content.course_structures.models import CourseStructure +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from ccx_keys.locator import CCXLocator + +from ..tasks import send_ccx_course_published + + +class TestSendCCXCoursePublished(ModuleStoreTestCase): + """unit tests for the send ccx course published task + """ + + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + def setUp(self): + """ + Set up tests + """ + super(TestSendCCXCoursePublished, self).setUp() + course = self.course = CourseFactory.create(org="edX", course="999", display_name="Run 666") + course2 = self.course2 = CourseFactory.create(org="edX", course="999a", display_name="Run 667") + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + self.ccx2 = CcxFactory(course_id=course.id, coach=coach) + self.ccx3 = CcxFactory(course_id=course.id, coach=coach) + self.ccx4 = CcxFactory(course_id=course2.id, coach=coach) + + def call_fut(self, course_key): + """Call the function under test + """ + send_ccx_course_published(unicode(course_key)) + + def test_signal_not_sent_for_ccx(self): + """Check that course published signal is not sent when course key is for a ccx + """ + course_key = CCXLocator.from_course_locator(self.course.id, self.ccx.id) + with mock_signal_receiver(SignalHandler.course_published) as receiver: + self.call_fut(course_key) + self.assertEqual(receiver.call_count, 0) + + def test_signal_sent_for_ccx(self): + """Check that course published signal is sent when course key is not for a ccx. + We have 4 ccx's, but only 3 are derived from the course id used here, so call + count must be 3 to confirm that all derived courses and no more got the signal. + """ + with mock_signal_receiver(SignalHandler.course_published) as receiver: + self.call_fut(self.course.id) + self.assertEqual(receiver.call_count, 3) + + def test_course_structure_generated(self): + """Check that course structure is generated after course published signal is sent + """ + ccx_structure = { + u"blocks": { + u"ccx-block-v1:edX+999+Run_666+ccx@1+type@course+block@course": { + u"block_type": u"course", + u"graded": False, + u"format": None, + u"usage_key": u"ccx-block-v1:edX+999+Run_666+ccx@1+type@course+block@course", + u"children": [ + ], + u"display_name": u"Run 666" + } + }, + u"root": u"ccx-block-v1:edX+999+Run_666+ccx@1+type@course+block@course" + } + course_key = CCXLocator.from_course_locator(self.course.id, self.ccx.id) + structure = CourseStructure.objects.filter(course_id=course_key) + # no structure exists before signal is called + self.assertEqual(len(structure), 0) + with mock_signal_receiver(SignalHandler.course_published) as receiver: + self.call_fut(self.course.id) + self.assertEqual(receiver.call_count, 3) + structure = CourseStructure.objects.get(course_id=course_key) + self.assertEqual(structure.structure, ccx_structure) + + def test_course_overview_deleted(self): + """Check that course overview is deleted after course published signal is sent + """ + course_key = CCXLocator.from_course_locator(self.course.id, self.ccx.id) + overview = CourseOverview(id=course_key) + overview.version = 1 + overview.save() + overview = CourseOverview.objects.filter(id=course_key) + self.assertEqual(len(overview), 1) + with mock_signal_receiver(SignalHandler.course_published) as receiver: + self.call_fut(self.course.id) + self.assertEqual(receiver.call_count, 3) + overview = CourseOverview.objects.filter(id=course_key) + self.assertEqual(len(overview), 0)