From 2f3c93ed9f6ae292d6f55ea60cac855ddae2c9ea Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Tue, 20 Sep 2022 12:38:32 -0400 Subject: [PATCH] feat: sync with exam service on course publish (#31015) Call into the exam service instead of the edx-proctoring plugin on course publish if the course_apps.exams_ida course waffle flag is enabled. This is an early step in moving away from edx-proctoring --- cms/djangoapps/contentstore/exams.py | 136 ++++++++++++++ cms/djangoapps/contentstore/proctoring.py | 3 + cms/djangoapps/contentstore/tasks.py | 9 +- .../contentstore/tests/test_exams.py | 173 ++++++++++++++++++ .../contentstore/tests/test_tasks.py | 34 +++- cms/envs/common.py | 3 + cms/envs/devstack-experimental.yml | 1 + cms/envs/production.py | 4 + lms/envs/devstack.py | 3 + lms/envs/test.py | 3 + 10 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 cms/djangoapps/contentstore/exams.py create mode 100644 cms/djangoapps/contentstore/tests/test_exams.py diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py new file mode 100644 index 0000000000..7dc2680d0a --- /dev/null +++ b/cms/djangoapps/contentstore/exams.py @@ -0,0 +1,136 @@ +""" +Code related to working with the exam service +""" + +import json +import logging + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from edx_rest_api_client.auth import SuppliedJwtAuth + +from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +from .views.helpers import is_item_in_course_tree + +log = logging.getLogger(__name__) +User = get_user_model() + + +def register_exams(course_key): + """ + This is typically called on a course published signal. The course is examined for sequences + that are marked as timed exams. Then these are registered with the exams service. + Likewise, if formerly registered exams are not included in the payload they will + be marked inactive by the exam service. + """ + if not settings.FEATURES.get('ENABLE_SPECIAL_EXAMS') or not exams_ida_enabled(course_key): + # if feature is not enabled then do a quick exit + return + + course = modulestore().get_course(course_key) + if course is None: + raise ItemNotFoundError("Course {} does not exist", str(course_key)) # lint-amnesty, pylint: disable=raising-format-tuple + + # get all sequences, since they can be marked as timed/proctored exams + _timed_exams = modulestore().get_items( + course_key, + qualifiers={ + 'category': 'sequential', + }, + settings={ + 'is_time_limited': True, + } + ) + + # filter out any potential dangling sequences + timed_exams = [ + timed_exam + for timed_exam in _timed_exams + if is_item_in_course_tree(timed_exam) + ] + + exams_list = [] + locations = [] + for timed_exam in timed_exams: + location = str(timed_exam.location) + msg = ( + 'Found {location} as an exam in course structure.'.format( + location=location + ) + ) + log.info(msg) + locations.append(location) + + exam_type = get_exam_type( + timed_exam.is_proctored_exam, + timed_exam.is_practice_exam, + timed_exam.is_onboarding_exam + ) + exams_list.append({ + 'course_id': str(course_key), + 'content_id': str(timed_exam.location), + 'exam_name': timed_exam.display_name, + 'time_limit_mins': timed_exam.default_time_limit_minutes, + 'due_date': timed_exam.due if not course.self_paced else None, + 'exam_type': exam_type, + 'is_active': True, + 'hide_after_due': timed_exam.hide_after_due, + # backend is only required for continued edx-proctoring support + 'backend': course.proctoring_provider, + }) + + try: + _patch_course_exams(exams_list, str(course_key)) + log.info(f'Successfully registered {locations} with exam service') + # pylint: disable=broad-except + except Exception as ex: + log.exception('Failed to register exams with exam API', exc_info=True) + raise ex + + +def get_exam_type(is_proctored, is_practice, is_onboarding): + """ + Get the exam type string based on the proctored, practice and onboarding + attributes. + """ + if is_proctored: + if is_onboarding: + exam_type = 'onboarding' + elif is_practice: + exam_type = 'practice_proctored' + else: + exam_type = 'proctored' + else: + exam_type = 'timed' + + return exam_type + + +def _get_exams_api_client(): + """ + Returns an API client which can be used to make Exams API requests. + """ + user = User.objects.get(username=settings.EXAMS_SERVICE_USERNAME) + jwt = create_jwt_for_user(user) + client = requests.Session() + client.auth = SuppliedJwtAuth(jwt) + + return client + + +def _patch_course_exams(exams_list, course_id): + """ + Make a PATCH request to update course exams + """ + url = f'{settings.EXAMS_SERVICE_URL}/exams/course_id/{course_id}/' + api_client = _get_exams_api_client() + + response = api_client.patch(url, data=json.dumps(exams_list)) + response.raise_for_status() + response = response.json() + return response diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index cf0704259b..d265232f83 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -1,5 +1,8 @@ """ Code related to the handling of Proctored Exams in Studio +using the edx-proctoring plugin. + +Courses using the exam IDA are handled by ./exams.py """ diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index eea41c3a43..aaee20fbb1 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -51,6 +51,7 @@ from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util.monitoring import monitor_import_failure from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines +from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled from openedx.core.djangoapps.discussions.tasks import update_unit_discussion_state_from_discussion_blocks from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse from openedx.core.lib.extract_tar import safetar_extractall @@ -242,13 +243,17 @@ def update_special_exams_and_publish(course_key_str): on_course_publish expects that the edx-proctoring subsystem has been refreshed before being executed, so both functions are called here synchronously. """ - from cms.djangoapps.contentstore.proctoring import register_special_exams + from cms.djangoapps.contentstore.exams import register_exams + from cms.djangoapps.contentstore.proctoring import register_special_exams as register_exams_legacy from openedx.core.djangoapps.credit.signals import on_course_publish course_key = CourseKey.from_string(course_key_str) LOGGER.info('Attempting to register exams for course %s', course_key_str) + + # Call the appropriate handler for either the exams IDA or the edx-proctoring plugin + register_exams_handler = register_exams if exams_ida_enabled(course_key) else register_exams_legacy try: - register_special_exams(course_key) + register_exams_handler(course_key) LOGGER.info('Successfully registered exams for course %s', course_key_str) # pylint: disable=broad-except except Exception as exception: diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py new file mode 100644 index 0000000000..5a25840bda --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_exams.py @@ -0,0 +1,173 @@ +""" +Test the exams service integration into Studio +""" +from datetime import datetime, timedelta +from unittest.mock import patch + +import ddt +from django.conf import settings +from edx_toggles.toggles.testutils import override_waffle_flag +from pytz import UTC + +from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish +from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA +from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_AMNESTY_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +@ddt.ddt +@override_waffle_flag(EXAMS_IDA, active=True) +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) +@patch('cms.djangoapps.contentstore.exams._patch_course_exams') +class TestExamService(ModuleStoreTestCase): + """ + Test for syncing exams to the exam service + """ + MODULESTORE = TEST_DATA_MONGO_AMNESTY_MODULESTORE + + def setUp(self): + """ + Initial data setup + """ + super().setUp() + + self.course = CourseFactory.create( + org='edX', + course='900', + run='test_run', + enable_proctored_exams=True, + proctoring_provider='null', + ) + self.chapter = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + self.course_key = str(self.course.id) + + # create one non-exam sequence + chapter2 = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Homework') + ItemFactory.create( + parent=chapter2, + category='sequential', + display_name='Homework 1', + graded=True, + is_time_limited=False, + due=datetime.now(UTC) + timedelta(minutes=60), + ) + + def _get_exams_url(self, course_id): + return f'{settings.EXAMS_SERVICE_URL}/exams/course_id/{course_id}/' + + @ddt.data( + (False, False, False, 'timed'), + (True, False, False, 'proctored'), + (True, True, False, 'practice_proctored'), + (True, True, True, 'onboarding'), + ) + @ddt.unpack + def test_publishing_exam(self, is_proctored_exam, is_practice_exam, + is_onboarding_exam, expected_type, mock_patch_course_exams): + """ + When a course is published it will register all exams sections with the exams service + """ + default_time_limit_minutes = 10 + + sequence = ItemFactory.create( + parent=self.chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=default_time_limit_minutes, + is_proctored_exam=is_proctored_exam, + is_practice_exam=is_practice_exam, + due=datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1), + hide_after_due=True, + is_onboarding_exam=is_onboarding_exam, + ) + + expected_exams = [{ + 'course_id': self.course_key, + 'content_id': str(sequence.location), + 'exam_name': sequence.display_name, + 'time_limit_mins': sequence.default_time_limit_minutes, + 'due_date': sequence.due, + 'exam_type': expected_type, + 'is_active': True, + 'hide_after_due': True, + # backend is only required for edx-proctoring support edx-exams will maintain LTI backends + 'backend': 'null', + }] + listen_for_course_publish(self, self.course.id) + mock_patch_course_exams.assert_called_once_with(expected_exams, self.course_key) + + def test_publish_no_exam(self, mock_patch_course_exams): + """ + Exam service is called with an empty list if there are no exam sections. + This will deactivate any currently active exams + """ + listen_for_course_publish(self, self.course.id) + mock_patch_course_exams.assert_called_once_with([], self.course_key) + + def test_dangling_exam(self, mock_patch_course_exams): + """ + Make sure we filter out all dangling items + """ + ItemFactory.create( + parent=self.chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_exam=True, + hide_after_due=False, + ) + self.store.delete_item(self.chapter.location, self.user.id) + + listen_for_course_publish(self, self.course.id) + mock_patch_course_exams.assert_called_once_with([], self.course_key) + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': False}) + def test_feature_flag_off(self, mock_patch_course_exams): + """ + Make sure the feature flag is honored + """ + ItemFactory.create( + parent=self.chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_proctored_exam=True, + hide_after_due=False, + ) + + listen_for_course_publish(self, self.course.id) + mock_patch_course_exams.assert_not_called() + + def test_self_paced_no_due_dates(self, mock_patch_course_exams): + self.course.self_paced = True + self.course = self.update_course(self.course, 1) + ItemFactory.create( + parent=self.chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=60, + is_proctored_exam=False, + is_practice_exam=False, + due=datetime.now(UTC) + timedelta(minutes=60), + hide_after_due=True, + is_onboarding_exam=False, + ) + listen_for_course_publish(self, self.course.id) + called_exams, called_course = mock_patch_course_exams.call_args[0] + assert called_exams[0]['due_date'] is None + + # now switch to instructor paced + # the exam will be updated with a due date + self.course.self_paced = False + self.course = self.update_course(self.course, 1) + listen_for_course_publish(self, self.course.id) + called_exams, called_course = mock_patch_course_exams.call_args[0] + assert called_exams[0]['due_date'] is not None diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index d037f1d99c..d0fcd78210 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -11,16 +11,18 @@ from uuid import uuid4 from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locator import CourseLocator from organizations.models import OrganizationCourse from organizations.tests.factories import OrganizationFactory from user_tasks.models import UserTaskArtifact, UserTaskStatus -from cms.djangoapps.contentstore.tasks import export_olx, rerun_course +from cms.djangoapps.contentstore.tasks import export_olx, update_special_exams_and_publish, rerun_course from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE @@ -167,3 +169,33 @@ class RerunCourseTaskTestCase(CourseTestCase): # lint-amnesty, pylint: disable= restricted_course=restricted_course, country=restricted_country ) + + +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +class RegisterExamsTaskTestCase(CourseTestCase): # pylint: disable=missing-class-docstring + + @mock.patch('cms.djangoapps.contentstore.exams.register_exams') + @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') + def test_exam_service_not_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): + """ edx-proctoring interface is called if exam service is not enabled """ + update_special_exams_and_publish(str(self.course.id)) + _mock_register_exams_proctoring.assert_called_once_with(self.course.id) + _mock_register_exams_service.assert_not_called() + + @mock.patch('cms.djangoapps.contentstore.exams.register_exams') + @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') + @override_waffle_flag(EXAMS_IDA, active=True) + def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mock_register_exams_service): + """ exams service interface is called if exam service is enabled """ + update_special_exams_and_publish(str(self.course.id)) + _mock_register_exams_proctoring.assert_not_called() + _mock_register_exams_service.assert_called_once_with(self.course.id) + + @mock.patch('cms.djangoapps.contentstore.exams.register_exams') + @mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams') + def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_register_exams_service): + """ credit requirements update signal fires even if exam registration fails """ + with mock.patch('openedx.core.djangoapps.credit.signals.on_course_publish') as course_publish: + _mock_register_exams_proctoring.side_effect = Exception('boom!') + update_special_exams_and_publish(str(self.course.id)) + course_publish.assert_called() diff --git a/cms/envs/common.py b/cms/envs/common.py index 1e3bfbe4bc..27e16e5c43 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2414,6 +2414,9 @@ ANALYTICS_DASHBOARD_NAME = 'Your Platform Name Here Insights' COMMENTS_SERVICE_URL = 'http://localhost:18080' COMMENTS_SERVICE_KEY = 'password' +EXAMS_SERVICE_URL = 'http://localhost:8740/api/v1' +EXAMS_SERVICE_USERNAME = 'edx_exams_worker' + FINANCIAL_REPORTS = { 'STORAGE_TYPE': 'localfs', 'BUCKET': None, diff --git a/cms/envs/devstack-experimental.yml b/cms/envs/devstack-experimental.yml index 9a96454a61..daa1225ee8 100644 --- a/cms/envs/devstack-experimental.yml +++ b/cms/envs/devstack-experimental.yml @@ -258,6 +258,7 @@ ENTERPRISE_API_URL: http://edx.devstack.lms:18000/enterprise/api/v1 ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS: {} ENTERPRISE_SERVICE_WORKER_USERNAME: enterprise_worker EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST: [] +EXAMS_API_URL: http://localhost:8740/api/v1 EXTRA_MIDDLEWARE_CLASSES: [] FACEBOOK_API_VERSION: v2.1 FACEBOOK_APP_ID: FACEBOOK_APP_ID diff --git a/cms/envs/production.py b/cms/envs/production.py index ece9b85a17..6431ad95c1 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -561,6 +561,10 @@ RETIREMENT_SERVICE_WORKER_USERNAME = ENV_TOKENS.get( RETIREMENT_SERVICE_WORKER_USERNAME ) +############### Settings for Exams #################### +EXAMS_SERVICE_URL = ENV_TOKENS.get('EXAMS_SERVICE_URL', EXAMS_SERVICE_URL) +EXAMS_SERVICE_USERNAME = ENV_TOKENS.get('EXAMS_SERVICE_USERNAME', EXAMS_SERVICE_USERNAME) + ############### Settings for edx-rbac ############### SYSTEM_WIDE_ROLE_CLASSES = ENV_TOKENS.get('SYSTEM_WIDE_ROLE_CLASSES') or SYSTEM_WIDE_ROLE_CLASSES diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index cdf6011eb8..d7c66cb202 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -238,6 +238,9 @@ COMMENTS_SERVICE_URL = 'http://edx.devstack.forum:4567' CREDENTIALS_INTERNAL_SERVICE_URL = 'http://edx.devstack.credentials:18150' CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150' +############## Exams CONFIGURATION SETTINGS #################### +EXAMS_SERVICE_URL = 'http://localhost:8740/api/v1' + ############################### BLOCKSTORE ##################################### BLOCKSTORE_API_URL = "http://edx.devstack.blockstore:18250/api/v1/" diff --git a/lms/envs/test.py b/lms/envs/test.py index 57de824ad0..ad9372e09b 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -611,6 +611,9 @@ PROCTORING_USER_OBFUSCATION_KEY = 'test_key' # (ref MST-637) PROCTORING_USER_OBFUSCATION_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' +############## Exams CONFIGURATION SETTINGS #################### +EXAMS_SERVICE_URL = 'http://exams.example.com/api/v1' + ############### Settings for Django Rate limit ##################### RATELIMIT_RATE = '2/m'