Merge pull request #31016 from openedx/varshamenon4/show-exam-info
feat: show exam info in course outline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user