From 7e3470db42b5ce765a9f98848b55d33e08e87e7e Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 2 Jun 2020 09:44:12 -0400 Subject: [PATCH] Expose was_ever_proctored_exam dict entry on xblock_info (#24040) In a follow-up PR, this entry will be used to validate on the client-side that a block that *is* or *ever was* a proctored/practice/onboarding exam cannot be configured as a different proctored/practice/onboarding exam, as that has led to problematic exam configuration states between edX and proctoring providers. MST-258 --- cms/djangoapps/contentstore/views/item.py | 36 ++++- .../contentstore/views/tests/test_item.py | 133 +++++++++++++++--- 2 files changed, 147 insertions(+), 22 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index c61a85c7c6..87bcfa7632 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -15,7 +15,12 @@ from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.utils.translation import ugettext as _ from django.views.decorators.http import require_http_methods -from edx_proctoring.api import does_backend_support_onboarding, get_exam_configuration_dashboard_url +from edx_proctoring.api import ( + does_backend_support_onboarding, + get_exam_by_content_id, + get_exam_configuration_dashboard_url +) +from edx_proctoring.exceptions import ProctoredExamNotFoundException from help_tokens.core import HelpUrlExpert from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryUsageLocator @@ -1244,6 +1249,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F xblock_info.update({ 'is_proctored_exam': xblock.is_proctored_exam, + 'was_ever_proctored_exam': _was_xblock_ever_proctored_exam( + course, xblock + ), 'online_proctoring_rules': rules_url, 'is_practice_exam': xblock.is_practice_exam, 'is_onboarding_exam': xblock.is_onboarding_exam, @@ -1295,6 +1303,32 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F return xblock_info +def _was_xblock_ever_proctored_exam(course, xblock): + """ + Determine whether this XBlock is or was ever configured as a proctored exam. + + If this block is *not* currently a proctored exam, the best way for us to tell + whether it was was *ever* configured as a proctored exam is by checking whether + the proctoring backend has an exam record associated with the block's ID. + If an exception is not raised, then we know that such a record exists, + indicating that this *was* once a proctored exam. + + Arguments: + course (CourseDescriptor) + xblock (XBlock) + + Returns: bool + """ + if xblock.is_proctored_exam: + return True + try: + get_exam_by_content_id(course.id, xblock.location) + except ProctoredExamNotFoundException: + return False + else: + return True + + def add_container_page_publishing_info(xblock, xblock_info): """ Adds information about the xblock's publish state to the supplied diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 70226c47bd..f9cf6eeb16 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -12,6 +12,7 @@ from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse +from edx_proctoring.exceptions import ProctoredExamNotFoundException from mock import Mock, PropertyMock, patch from opaque_keys import InvalidKeyError from opaque_keys.edx.asides import AsideUsageKeyV2 @@ -32,6 +33,7 @@ from xblock.validation import ValidationMessage from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url, reverse_usage_url +from contentstore.views import item as item_module from contentstore.views.component import component_handler, get_component_templates from contentstore.views.item import ( ALWAYS, @@ -2473,7 +2475,6 @@ class TestXBlockInfo(ItemTest): """ Unit tests for XBlock's outline handling. """ - def setUp(self): super(TestXBlockInfo, self).setUp() user_id = self.user.id @@ -2783,15 +2784,37 @@ class TestXBlockInfo(ItemTest): else: self.assertIsNone(xblock_info.get('child_info', None)) - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) - @patch('contentstore.views.item.does_backend_support_onboarding') - @patch('contentstore.views.item.get_exam_configuration_dashboard_url') - def test_proctored_exam_xblock_info(self, get_exam_configuration_dashboard_url_patch, - does_backend_support_onboarding_patch): + +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) +class TestProctoredXBlockInfo(ItemTest): + """ + Unit tests for XBlock outline handling, specific to proctored exam XBlocks. + """ + patch_get_exam_configuration_dashboard_url = patch.object( + item_module, 'get_exam_configuration_dashboard_url', return_value='test_url' + ) + patch_does_backend_support_onboarding = patch.object( + item_module, 'does_backend_support_onboarding', return_value=True + ) + patch_get_exam_by_content_id_success = patch.object( + item_module, 'get_exam_by_content_id' + ) + patch_get_exam_by_content_id_not_found = patch.object( + item_module, 'get_exam_by_content_id', side_effect=ProctoredExamNotFoundException + ) + + def setUp(self): + super().setUp() + user_id = self.user.id + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Week 1", user_id=user_id, + highlights=['highlight'], + ) self.course.enable_proctored_exams = True self.course.save() self.store.update_item(self.course, self.user.id) + def test_proctoring_is_enabled_for_course(self): course = modulestore().get_item(self.course.location) xblock_info = create_xblock_info( course, @@ -2799,31 +2822,99 @@ class TestXBlockInfo(ItemTest): include_children_predicate=ALWAYS, ) # exam proctoring should be enabled and time limited. - self.assertEqual(xblock_info['enable_proctored_exams'], True) + assert xblock_info['enable_proctored_exams'] + @patch_get_exam_configuration_dashboard_url + @patch_does_backend_support_onboarding + @patch_get_exam_by_content_id_success + def test_proctored_exam_xblock_info( + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding, + mock_get_exam_configuration_dashboard_url, + ): sequential = ItemFactory.create( - parent_location=self.chapter.location, category='sequential', - display_name="Test Lesson 1", user_id=self.user.id, - is_proctored_exam=True, is_time_limited=True, - default_time_limit_minutes=100, is_onboarding_exam=False + parent_location=self.chapter.location, + category='sequential', + display_name="Test Lesson 1", + user_id=self.user.id, + is_proctored_exam=True, + is_time_limited=True, + default_time_limit_minutes=100, + is_onboarding_exam=False, ) sequential = modulestore().get_item(sequential.location) - - get_exam_configuration_dashboard_url_patch.return_value = 'test_url' - does_backend_support_onboarding_patch.return_value = True xblock_info = create_xblock_info( sequential, include_child_info=True, include_children_predicate=ALWAYS, ) # exam proctoring should be enabled and time limited. - self.assertEqual(xblock_info['is_proctored_exam'], True) - self.assertEqual(xblock_info['is_time_limited'], True) - self.assertEqual(xblock_info['default_time_limit_minutes'], 100) - self.assertEqual(xblock_info['proctoring_exam_configuration_link'], 'test_url') - self.assertEqual(xblock_info['supports_onboarding'], True) - self.assertEqual(xblock_info['is_onboarding_exam'], False) - get_exam_configuration_dashboard_url_patch.assert_called_with(self.course.id, xblock_info['id']) + assert xblock_info['is_proctored_exam'] + assert xblock_info['was_ever_proctored_exam'] + assert xblock_info['is_time_limited'] + assert xblock_info['default_time_limit_minutes'] == 100 + assert xblock_info['proctoring_exam_configuration_link'] == 'test_url' + assert xblock_info['supports_onboarding'] + assert not xblock_info['is_onboarding_exam'] + mock_get_exam_configuration_dashboard_url.assert_called_with(self.course.id, xblock_info['id']) + assert mock_get_exam_by_content_id.call_count == 0 + + @patch_get_exam_configuration_dashboard_url + @patch_does_backend_support_onboarding + @patch_get_exam_by_content_id_success + def test_xblock_was_ever_proctored_exam( + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding_patch, + _mock_get_exam_configuration_dashboard_url, + ): + sequential = ItemFactory.create( + parent_location=self.chapter.location, + category='sequential', + display_name="Test Lesson 1", + user_id=self.user.id, + is_proctored_exam=False, + is_time_limited=True, + default_time_limit_minutes=100, + is_onboarding_exam=False, + ) + sequential = modulestore().get_item(sequential.location) + xblock_info = create_xblock_info( + sequential, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + assert xblock_info['was_ever_proctored_exam'] + assert mock_get_exam_by_content_id.call_count == 1 + + @patch_get_exam_configuration_dashboard_url + @patch_does_backend_support_onboarding + @patch_get_exam_by_content_id_not_found + def test_xblock_was_never_proctored_exam( + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding_patch, + _mock_get_exam_configuration_dashboard_url, + ): + sequential = ItemFactory.create( + parent_location=self.chapter.location, + category='sequential', + display_name="Test Lesson 1", + user_id=self.user.id, + is_proctored_exam=False, + is_time_limited=True, + default_time_limit_minutes=100, + is_onboarding_exam=False, + ) + sequential = modulestore().get_item(sequential.location) + xblock_info = create_xblock_info( + sequential, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + assert not xblock_info['was_ever_proctored_exam'] + assert mock_get_exam_by_content_id.call_count == 1 class TestLibraryXBlockInfo(ModuleStoreTestCase):