Merge pull request #31016 from openedx/varshamenon4/show-exam-info

feat: show exam info in course outline
This commit is contained in:
Varsha
2022-10-26 13:25:58 -04:00
committed by GitHub
4 changed files with 152 additions and 27 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):
"""