diff --git a/lms/djangoapps/course_api/blocks/transformers/milestones.py b/lms/djangoapps/course_api/blocks/transformers/milestones.py index 5179f989e8..bddaa10f50 100644 --- a/lms/djangoapps/course_api/blocks/transformers/milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/milestones.py @@ -6,12 +6,14 @@ Milestones Transformer import logging from django.conf import settings +from django.utils.translation import gettext as _ from edx_proctoring.api import get_attempt_status_summary from edx_proctoring.exceptions import ProctoredExamNotFoundException from common.djangoapps.student.models import EntranceExamConfiguration from common.djangoapps.util import milestones_helpers from openedx.core.djangoapps.content.block_structure.transformer import BlockStructureTransformer +from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled log = logging.getLogger(__name__) @@ -113,17 +115,14 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer): """ For special exams, add the special exam information to the course blocks. """ - special_exam_attempt_context = None - try: - # Calls into edx_proctoring subsystem to get relevant special exam information. - # This will return None, if (user, course_id, content_id) is not applicable. - special_exam_attempt_context = get_attempt_status_summary( - usage_info.user.id, - str(block_key.course_key), - str(block_key) - ) - except ProctoredExamNotFoundException as ex: - log.exception(ex) + special_exam_attempt_context = self._generate_special_exam_attempt_context( + block_structure.get_xblock_field(block_key, 'is_practice_exam'), + block_structure.get_xblock_field(block_key, 'is_proctored_enabled'), + block_structure.get_xblock_field(block_key, 'is_timed_exam'), + usage_info.user.id, + block_key.course_key, + str(block_key) + ) if special_exam_attempt_context: # This user has special exam context for this block so add it. @@ -172,3 +171,43 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer): return True return False + + def _generate_special_exam_attempt_context(self, is_practice_exam, is_proctored_enabled, + is_timed_exam, user_id, course_key, block_key): + """ + Helper method which generates the special exam attempt context. + Either calls into proctoring or, if exams ida waffle flag on, then get internally. + Note: This method duplicates the method by the same name in: + openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py + For now, both methods exist to avoid importing from different directories. In the future, + we could potentially consolidate if there is a good common place to implement. + """ + special_exam_attempt_context = None + + # if exams waffle flag enabled, get exam type internally + if exams_ida_enabled(course_key): + # add short description based on exam type + if is_practice_exam: + exam_type = _('Practice Exam') + elif is_proctored_enabled: + exam_type = _('Proctored Exam') + elif is_timed_exam: + exam_type = _('Timed Exam') + else: # sets a default, though considered impossible + log.info('Using default Exam value for exam type.') + exam_type = _('Exam') + + summary = {'short_description': exam_type, } + special_exam_attempt_context = summary + else: + try: + # Calls into edx_proctoring subsystem to get relevant special exam information. + special_exam_attempt_context = get_attempt_status_summary( + user_id, + str(course_key), + block_key + ) + except ProctoredExamNotFoundException as ex: + log.exception(ex) + + return special_exam_attempt_context diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py index a3c719f318..bfde20ca1f 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -9,10 +9,12 @@ import ddt from milestones.tests.utils import MilestonesTestCaseMixin from common.djangoapps.student.tests.factories import CourseEnrollmentFactory +from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.course_blocks.transformers.tests.helpers import CourseStructureTestCase from lms.djangoapps.gating import api as lms_gating_api from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers +from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from openedx.core.lib.gating import api as gating_api from ..milestones import MilestonesAndSpecialExamsTransformer @@ -223,3 +225,31 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM self.transformers, ) assert set(block_structure.get_block_keys()) == set(self.get_block_key_set(self.blocks, *expected_blocks)) + + @override_waffle_flag(EXAMS_IDA, active=False) + @patch('lms.djangoapps.course_api.blocks.transformers.milestones.get_attempt_status_summary') + def test_exams_ida_flag_off(self, mock_get_attempt_status_summary): + self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(True)]) + self.course.enable_subsection_gating = True + self.setup_gated_section(self.blocks['H'], self.blocks['A']) + expected_blocks = ( + 'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K', 'H', + 'I') # lint-amnesty, pylint: disable=line-too-long + self.get_blocks_and_check_against_expected(self.user, expected_blocks) + + # Ensure that call is made to get_attempt_status_summary + assert mock_get_attempt_status_summary.call_count > 0 + + @override_waffle_flag(EXAMS_IDA, active=True) + @patch('lms.djangoapps.course_api.blocks.transformers.milestones.get_attempt_status_summary') + def test_exams_ida_flag_on(self, mock_get_attempt_status_summary): + self.transformers = BlockStructureTransformers([self.TRANSFORMER_CLASS_TO_TEST(True)]) + self.course.enable_subsection_gating = True + self.setup_gated_section(self.blocks['H'], self.blocks['A']) + expected_blocks = ( + 'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'TimedExam', 'J', 'K', 'H', + 'I') # lint-amnesty, pylint: disable=line-too-long + self.get_blocks_and_check_against_expected(self.user, expected_blocks) + + # Ensure that no calls are made to get_attempt_status_summary + assert mock_get_attempt_status_summary.call_count == 0 diff --git a/openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py b/openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py index efa0928a22..124b766a94 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py @@ -16,9 +16,12 @@ from edx_proctoring.api import get_attempt_status_summary from edx_proctoring.exceptions import ProctoredExamNotFoundException from django.conf import settings from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ from ...data import SpecialExamAttemptData, UserCourseOutlineData from .base import OutlineProcessor +from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled + User = get_user_model() log = logging.getLogger(__name__) @@ -53,22 +56,14 @@ class SpecialExamsOutlineProcessor(OutlineProcessor): if not bool(sequence.exam): continue - special_exam_attempt_context = None - try: - # Calls into edx_proctoring subsystem to get relevant special exam information. - # This will return None, if (user, course_id, content_id) is not applicable. - special_exam_attempt_context = get_attempt_status_summary( - self.user.id, - str(self.course_key), - str(sequence.usage_key) - ) - except ProctoredExamNotFoundException: - log.info( - 'No exam found for {sequence_key} in {course_key}'.format( - sequence_key=sequence.usage_key, - course_key=self.course_key - ) - ) + special_exam_attempt_context = self._generate_special_exam_attempt_context( + sequence.exam.is_practice_exam, + sequence.exam.is_proctored_enabled, + sequence.exam.is_time_limited, + self.user.id, + self.course_key, + str(sequence.usage_key) + ) if special_exam_attempt_context: # Return exactly the same format as the edx_proctoring API response @@ -77,3 +72,39 @@ class SpecialExamsOutlineProcessor(OutlineProcessor): return SpecialExamAttemptData( sequences=sequences, ) + + def _generate_special_exam_attempt_context(self, is_practice_exam, is_proctored_enabled, + is_timed_exam, user_id, course_key, block_key): + """ + Helper method which generates the special exam attempt context. + Either calls into proctoring or, if exams ida waffle flag on, then get internally. + """ + special_exam_attempt_context = None + + # if exams waffle flag enabled, get exam type internally + if exams_ida_enabled(course_key): + # add short description based on exam type + if is_practice_exam: + exam_type = _('Practice Exam') + elif is_proctored_enabled: + exam_type = _('Proctored Exam') + elif is_timed_exam: + exam_type = _('Timed Exam') + else: # sets a default, though considered impossible + log.info('Using default Exam value for exam type.') + exam_type = _('Exam') + + summary = {'short_description': exam_type, } + special_exam_attempt_context = summary + else: + try: + # Calls into edx_proctoring subsystem to get relevant special exam information. + special_exam_attempt_context = get_attempt_status_summary( + user_id, + str(course_key), + block_key + ) + except ProctoredExamNotFoundException as ex: + log.exception(ex) + + return special_exam_attempt_context diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py index 9eb45e64ad..805066d1d6 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py @@ -18,6 +18,7 @@ import attr import ddt import pytest +from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG from common.djangoapps.course_modes.models import CourseMode @@ -1072,6 +1073,30 @@ class SpecialExamsTestCase(OutlineProcessorTestCase): # lint-amnesty, pylint: d assert mock_get_attempt_status_summary.call_count == 0 assert len(student_details.special_exam_attempts.sequences) == 0 + @override_waffle_flag(EXAMS_IDA, active=True) + @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True}) + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.special_exams.get_attempt_status_summary') + def test_special_exam_attempt_data_exams_ida_flag_on(self, mock_get_attempt_status_summary): + _, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Ensure that no calls are made to get_attempt_status_summary + assert mock_get_attempt_status_summary.call_count == 0 + + @override_waffle_flag(EXAMS_IDA, active=True) + @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True}) + def test_special_exam_attempt_data_exam_type(self): + _, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Ensure that exam type is correct for proctored exam + assert self.seq_proctored_exam_key in student_details.special_exam_attempts.sequences + attempt_summary = student_details.special_exam_attempts.sequences[self.seq_proctored_exam_key] + assert type(attempt_summary) == dict # lint-amnesty, pylint: disable=unidiomatic-typecheck + assert attempt_summary["short_description"] == "Proctored Exam" + class VisbilityTestCase(OutlineProcessorTestCase): """