diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index f31f57fc06..07c6e3524f 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -16,15 +16,28 @@ from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey, UsageKey from ..data import ( - CourseOutlineData, CourseSectionData, CourseLearningSequenceData, - UserCourseOutlineData, UserCourseOutlineDetailsData, VisibilityData, - CourseVisibility + CourseLearningSequenceData, + CourseOutlineData, + CourseSectionData, + CourseVisibility, + ExamData, + UserCourseOutlineData, + UserCourseOutlineDetailsData, + VisibilityData, ) from ..models import ( - CourseSection, CourseSectionSequence, CourseContext, LearningContext, LearningSequence + CourseSection, + CourseSectionSequence, + CourseContext, + CourseSequenceExam, + LearningContext, + LearningSequence ) from .permissions import can_see_all_content +from .processors.content_gating import ContentGatingOutlineProcessor +from .processors.milestones import MilestonesOutlineProcessor from .processors.schedule import ScheduleOutlineProcessor +from .processors.special_exams import SpecialExamsOutlineProcessor from .processors.visibility import VisibilityOutlineProcessor from .processors.enrollment import EnrollmentOutlineProcessor @@ -67,13 +80,23 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData: section_sequence_models = CourseSectionSequence.objects \ .filter(course_context=course_context) \ .order_by('ordering') \ - .select_related('sequence') + .select_related('sequence', 'exam') # Build mapping of section.id keys to sequence lists. sec_ids_to_sequence_list = defaultdict(list) for sec_seq_model in section_sequence_models: sequence_model = sec_seq_model.sequence + + try: + exam_data = ExamData( + is_practice_exam=sec_seq_model.exam.is_practice_exam, + is_proctored_enabled=sec_seq_model.exam.is_proctored_enabled, + is_time_limited=sec_seq_model.exam.is_time_limited + ) + except CourseSequenceExam.DoesNotExist: + exam_data = ExamData() + sequence_data = CourseLearningSequenceData( usage_key=sequence_model.usage_key, title=sequence_model.title, @@ -81,7 +104,8 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData: visibility=VisibilityData( hide_from_toc=sec_seq_model.hide_from_toc, visible_to_staff_only=sec_seq_model.visible_to_staff_only, - ) + ), + exam=exam_data ) sec_ids_to_sequence_list[sec_seq_model.section_id].append(sequence_data) @@ -104,6 +128,7 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData: published_at=course_context.learning_context.published_at, published_version=course_context.learning_context.published_version, days_early_for_beta=course_context.days_early_for_beta, + entrance_exam_id=course_context.entrance_exam_id, sections=sections_data, self_paced=course_context.self_paced, course_visibility=CourseVisibility(course_context.course_visibility), @@ -163,10 +188,12 @@ def get_user_course_outline_details(course_key: CourseKey, course_key, user, at_time ) schedule_processor = processors['schedule'] + special_exams_processor = processors['special_exams'] return UserCourseOutlineDetailsData( outline=user_course_outline, - schedule=schedule_processor.schedule_data(user_course_outline) + schedule=schedule_processor.schedule_data(user_course_outline), + special_exam_attempts=special_exams_processor.exam_data(user_course_outline) ) @@ -181,12 +208,13 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # released. These do not need to be run for staff users. This is where we # would add in pluggability for OutlineProcessors down the road. processor_classes = [ + ('content_gating', ContentGatingOutlineProcessor), + ('milestones', MilestonesOutlineProcessor), ('schedule', ScheduleOutlineProcessor), + ('special_exams', SpecialExamsOutlineProcessor), ('visibility', VisibilityOutlineProcessor), ('enrollment', EnrollmentOutlineProcessor), # Future: - # ('content_gating', ContentGatingOutlineProcessor), - # ('milestones', MilestonesOutlineProcessor), # ('user_partitions', UserPartitionsOutlineProcessor), ] @@ -225,6 +253,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, 'title', 'published_at', 'published_version', + 'entrance_exam_id', 'sections', 'self_paced', 'course_visibility', @@ -279,6 +308,7 @@ def _update_course_context(course_outline: CourseOutlineData): 'course_visibility': course_outline.course_visibility.value, 'days_early_for_beta': course_outline.days_early_for_beta, 'self_paced': course_outline.self_paced, + 'entrance_exam_id': course_outline.entrance_exam_id, } ) if created: @@ -349,7 +379,7 @@ def _update_course_section_sequences(course_outline: CourseOutlineData, course_c ordering = 0 for section_data in course_outline.sections: for sequence_data in section_data.sequences: - CourseSectionSequence.objects.update_or_create( + course_section_sequence, _ = CourseSectionSequence.objects.update_or_create( course_context=course_context, section=section_models[section_data.usage_key], sequence=sequence_models[sequence_data.usage_key], @@ -361,3 +391,17 @@ def _update_course_section_sequences(course_outline: CourseOutlineData, course_c }, ) ordering += 1 + + # If a sequence is an exam, update or create an exam record + if bool(sequence_data.exam): + CourseSequenceExam.objects.update_or_create( + course_section_sequence=course_section_sequence, + defaults={ + 'is_practice_exam': sequence_data.exam.is_practice_exam, + 'is_proctored_enabled': sequence_data.exam.is_proctored_enabled, + 'is_time_limited': sequence_data.exam.is_time_limited, + }, + ) + else: + # Otherwise, delete any exams associated with it + CourseSequenceExam.objects.filter(course_section_sequence=course_section_sequence).delete() diff --git a/openedx/core/djangoapps/content/learning_sequences/api/processors/content_gating.py b/openedx/core/djangoapps/content/learning_sequences/api/processors/content_gating.py new file mode 100644 index 0000000000..534ab922aa --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/content_gating.py @@ -0,0 +1,74 @@ +import logging +from datetime import datetime + +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey +from student.models import EntranceExamConfiguration +from util import milestones_helpers + +from .base import OutlineProcessor + +User = get_user_model() +log = logging.getLogger(__name__) + + +class ContentGatingOutlineProcessor(OutlineProcessor): + """ + Responsible for applying all content gating outline processing. + + This includes: + - Entrance Exams + - Chapter gated content + """ + def __init__(self, course_key: CourseKey, user: User, at_time: datetime): + super().__init__(course_key, user, at_time) + self.required_content = None + self.can_skip_entrance_exam = False + + def load_data(self): + """ + Get the required content for the course, and whether + or not the user can skip the entrance exam. + """ + self.required_content = milestones_helpers.get_required_content(self.course_key, self.user) + + if self.user.is_authenticated: + self.can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam( + self.user, self.course_key + ) + + def inaccessible_sequences(self, full_course_outline): + """ + Mark any section that is gated by required content as inaccessible + """ + if full_course_outline.entrance_exam_id and self.can_skip_entrance_exam: + self.required_content = [ + content + for content in self.required_content + if not content == full_course_outline.entrance_exam_id + ] + + inaccessible = set() + for section in full_course_outline.sections: + if self.gated_by_required_content(section.usage_key): + inaccessible |= { + seq.usage_key + for seq in section.sequences + } + + return inaccessible + + def gated_by_required_content(self, section_usage_key): + """ + Returns True if the current section associated with the usage_key should be gated by the given required_content. + Returns False otherwise. + """ + if not self.required_content: + return False + + # This should always be a chapter block + assert section_usage_key.block_type == 'chapter' + if str(section_usage_key) not in self.required_content: + return True + + return False diff --git a/openedx/core/djangoapps/content/learning_sequences/api/processors/milestones.py b/openedx/core/djangoapps/content/learning_sequences/api/processors/milestones.py new file mode 100644 index 0000000000..5a92a7760d --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/milestones.py @@ -0,0 +1,41 @@ +import logging + +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey +from util import milestones_helpers + +from .base import OutlineProcessor + +User = get_user_model() +log = logging.getLogger(__name__) + + +class MilestonesOutlineProcessor(OutlineProcessor): + """ + Responsible for applying all general course milestones outline processing. + + This does not include Entrance Exams (see `ContentGatingOutlineProcessor`), + or Special Exams (see `SpecialExamsOutlineProcessor`) + """ + def inaccessible_sequences(self, full_course_outline): + """ + Returns the set of sequence usage keys for which the + user has pending milestones + """ + inaccessible = set() + for section in full_course_outline.sections: + inaccessible |= { + seq.usage_key + for seq in section.sequences + if self.has_pending_milestones(seq.usage_key) + } + + return inaccessible + + def has_pending_milestones(self, usage_key): + return bool(milestones_helpers.get_course_content_milestones( + str(self.course_key), + str(usage_key), + 'requires', + self.user.id + )) 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 new file mode 100644 index 0000000000..ee656ab24c --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/special_exams.py @@ -0,0 +1,79 @@ +""" +As currently designed, this processor ignores the course specific +`Enable Timed Exams` setting when determining whether or not it should +remove keys and/or supplement exam data. This matches the exact behavior +of `MilestonesAndSpecialExamsTransformer`. It is not entirely clear if +the behavior should be modified, so it has been decided to consider any +necessary fixes in a new ticket. + +Please see the PR and discussion linked below for further context +https://github.com/edx/edx-platform/pull/24545#discussion_r501738511 +""" + +import logging + +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 ...data import SpecialExamAttemptData, UserCourseOutlineData +from .base import OutlineProcessor + +User = get_user_model() +log = logging.getLogger(__name__) + + +class SpecialExamsOutlineProcessor(OutlineProcessor): + """ + Responsible for applying all outline processing related to special exams. + """ + def load_data(self): + """ + Check if special exams are enabled + """ + self.special_exams_enabled = settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) + + def exam_data(self, pruned_course_outline: UserCourseOutlineData) -> SpecialExamAttemptData: + """ + Return supplementary special exam information for this outline. + + Be careful to pass in a UserCourseOutlineData - i.e. an outline that has + already been pruned to what a user is allowed to see. That way, we can + use this to make sure that we're not returning data about + LearningSequences that the user can't see because it was hidden by a + different OutlineProcessor. + """ + sequences = {} + if self.special_exams_enabled: + for section in pruned_course_outline.sections: + for sequence in section.sequences: + # Don't bother checking for information + # on non-exam sequences + 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 + ) + ) + + if special_exam_attempt_context: + # Return exactly the same format as the edx_proctoring API response + sequences[sequence.usage_key] = special_exam_attempt_context + + return SpecialExamAttemptData( + sequences=sequences, + ) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py index 13a83f6ffa..47ed7d2579 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_data.py @@ -32,6 +32,7 @@ class TestCourseOutlineData(TestCase): title="Exciting Test Course!", published_at=datetime(2020, 5, 19, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2014", + entrance_exam_id=None, days_early_for_beta=None, sections=generate_sections(cls.course_key, [3, 2]), self_paced=False, 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 c21799477c..d30a4e2af4 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 @@ -3,9 +3,12 @@ Top level API tests. Tests API public contracts only. Do not import/create/mock models for this app. """ from datetime import datetime, timezone +from mock import patch import attr +from django.conf import settings from django.contrib.auth.models import AnonymousUser, User +from edx_proctoring.exceptions import ProctoredExamNotFoundException from edx_when.api import set_dates_for_course from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator @@ -18,7 +21,14 @@ from common.djangoapps.student.auth import user_has_role from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseBetaTesterRole -from ...data import CourseLearningSequenceData, CourseOutlineData, CourseSectionData, CourseVisibility, VisibilityData +from ...data import ( + CourseLearningSequenceData, + CourseOutlineData, + CourseSectionData, + CourseVisibility, + ExamData, + VisibilityData, +) from ..outlines import ( get_course_outline, get_user_course_outline, @@ -44,6 +54,7 @@ class CourseOutlineTestCase(CacheIsolationTestCase): title="Roundtrip Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2015", + entrance_exam_id=None, days_early_for_beta=None, sections=generate_sections(cls.course_key, [2, 2]), self_paced=False, @@ -138,6 +149,7 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): title="User Outline Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, sections=generate_sections(cls.course_key, [2, 1, 3]), self_paced=False, @@ -146,9 +158,9 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): replace_course_outline(cls.simple_outline) # Enroll student in the course - CourseEnrollment.enroll(user=cls.student, course_key=cls.course_key, mode="audit") + cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") # Enroll beta tester in the course - CourseEnrollment.enroll(user=cls.beta_tester, course_key=cls.course_key, mode="audit") + cls.beta_tester.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") def test_simple_outline(self): """This outline is the same for everyone.""" @@ -182,7 +194,295 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): assert global_staff_outline_details.outline == global_staff_outline -class ScheduleTestCase(CacheIsolationTestCase): +class OutlineProcessorTestCase(CacheIsolationTestCase): + @classmethod + def setUpTestData(cls): + cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") + + # Users... + cls.global_staff = User.objects.create_user( + 'global_staff', email='gstaff@example.com', is_staff=True + ) + cls.student = User.objects.create_user( + 'student', email='student@example.com', is_staff=False + ) + cls.beta_tester = BetaTesterFactory(course_key=cls.course_key) + cls.anonymous_user = AnonymousUser() + + cls.all_seq_keys = [] + + def get_details(self, at_time): + staff_details = get_user_course_outline_details(self.course_key, self.global_staff, at_time) + student_details = get_user_course_outline_details(self.course_key, self.student, at_time) + beta_tester_details = get_user_course_outline_details(self.course_key, self.beta_tester, at_time) + return staff_details, student_details, beta_tester_details + + @classmethod + def set_sequence_keys(cls, keys): + cls.all_seq_keys = keys + + def get_sequence_keys(self, exclude=None): + if exclude is None: + exclude = [] + if not isinstance(exclude, list): + raise TypeError("`exclude` must be a list of keys to be excluded") + return [key for key in self.all_seq_keys if key not in exclude] + + +class ContentGatingTestCase(OutlineProcessorTestCase): + """ + Content Gating specific outline tests + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # The UsageKeys we're going to set up for content gating tests. + cls.entrance_exam_section_key = cls.course_key.make_usage_key('chapter', 'entrance_exam') + cls.entrance_exam_seq_key = cls.course_key.make_usage_key('sequential', 'entrance_exam') + cls.open_section_key = cls.course_key.make_usage_key('chapter', 'open') + cls.open_seq_key = cls.course_key.make_usage_key('sequential', 'open') + cls.gated_section_key = cls.course_key.make_usage_key('chapter', 'gated') + cls.gated_seq_key = cls.course_key.make_usage_key('sequential', 'gated') + + cls.set_sequence_keys([ + cls.entrance_exam_seq_key, + cls.open_seq_key, + cls.gated_seq_key, + ]) + + set_dates_for_course( + cls.course_key, + [ + ( + cls.course_key.make_usage_key('course', 'course'), + {'start': datetime(2020, 5, 10, tzinfo=timezone.utc)} + ), + ( + cls.entrance_exam_section_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.entrance_exam_seq_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.open_section_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.open_seq_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.gated_section_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.gated_seq_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ] + ) + + visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) + cls.outline = CourseOutlineData( + course_key=cls.course_key, + title="User Outline Test Course!", + published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), + published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=str(cls.entrance_exam_section_key), + days_early_for_beta=None, + self_paced=False, + course_visibility=CourseVisibility.PRIVATE, + sections=[ + CourseSectionData( + usage_key=cls.entrance_exam_section_key, + title="Entrance Exam", + visibility=visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=cls.entrance_exam_seq_key, + title='Entrance Exam', + visibility=visibility + ), + ] + ), + CourseSectionData( + usage_key=cls.open_section_key, + title="Open Section", + visibility=visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=cls.open_seq_key, + title='Open Sequence', + visibility=visibility + ), + ] + ), + CourseSectionData( + usage_key=cls.gated_section_key, + title="Gated Section", + visibility=visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=cls.gated_seq_key, + title='Gated Sequence', + visibility=visibility + ), + ] + ), + ] + ) + replace_course_outline(cls.outline) + + # Enroll student in the course + cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="verified") + + """ + Currently returns all, and only, sequences in required content, not just the first. + This logic matches the existing transformer. Is this right? + """ + + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.content_gating.EntranceExamConfiguration.user_can_skip_entrance_exam') + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.content_gating.milestones_helpers.get_required_content') + def test_user_can_skip_entrance_exam(self, required_content_mock, user_can_skip_entrance_exam_mock): + required_content_mock.return_value = [str(self.entrance_exam_section_key)] + user_can_skip_entrance_exam_mock.return_value = True + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 3 + + # Student can access all sequences + assert len(student_details.outline.accessible_sequences) == 3 + + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.content_gating.EntranceExamConfiguration.user_can_skip_entrance_exam') + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.content_gating.milestones_helpers.get_required_content') + def test_user_can_not_skip_entrance_exam(self, required_content_mock, user_can_skip_entrance_exam_mock): + required_content_mock.return_value = [str(self.entrance_exam_section_key)] + user_can_skip_entrance_exam_mock.return_value = False + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 3 + + # Student can access only the entrance exam sequence + assert len(student_details.outline.accessible_sequences) == 1 + assert self.entrance_exam_seq_key in student_details.outline.accessible_sequences + for key in self.get_sequence_keys(exclude=[self.entrance_exam_seq_key]): + assert key not in student_details.outline.accessible_sequences + + +class MilestonesTestCase(OutlineProcessorTestCase): + """ + Milestones specific outline tests + """ + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # The UsageKeys we're going to set up for milestone tests. + cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1') + cls.open_seq_key = cls.course_key.make_usage_key('sequential', 'open') + cls.milestone_required_seq_key = cls.course_key.make_usage_key('sequential', 'milestone_required') + + cls.set_sequence_keys([ + cls.open_seq_key, + cls.milestone_required_seq_key, + ]) + + set_dates_for_course( + cls.course_key, + [ + ( + cls.course_key.make_usage_key('course', 'course'), + {'start': datetime(2020, 5, 10, tzinfo=timezone.utc)} + ), + ( + cls.section_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.open_seq_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.milestone_required_seq_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ] + ) + + visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) + cls.outline = CourseOutlineData( + course_key=cls.course_key, + title="User Outline Test Course!", + published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), + published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, + days_early_for_beta=None, + self_paced=False, + course_visibility=CourseVisibility.PRIVATE, + sections=[ + CourseSectionData( + usage_key=cls.section_key, + title="Chapter 1", + visibility=visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=cls.open_seq_key, + title='Open Sequence', + visibility=visibility + ), + CourseLearningSequenceData( + usage_key=cls.milestone_required_seq_key, + title='Milestone Required Sequence', + visibility=visibility + ), + ] + ), + ] + ) + replace_course_outline(cls.outline) + + # Enroll student in the course + cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") + + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.milestones.milestones_helpers.get_course_content_milestones') + def test_user_can_skip_entrance_exam(self, get_course_content_milestones_mock): + # Only return that there are milestones required for the + # milestones_required_seq_key usage key + def get_milestones_side_effect(_course_key, usage_key, _milestone_type, _user_id): + return usage_key == str(self.milestone_required_seq_key) + + get_course_content_milestones_mock.side_effect = get_milestones_side_effect + + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 2 + + # Student can access only the open sequence, but not milestone required sequence + assert len(student_details.outline.accessible_sequences) == 1 + assert self.open_seq_key in student_details.outline.accessible_sequences + assert self.milestone_required_seq_key not in student_details.outline.accessible_sequences + + +class ScheduleTestCase(OutlineProcessorTestCase): """ Schedule-specific Outline tests. @@ -194,18 +494,7 @@ class ScheduleTestCase(CacheIsolationTestCase): @classmethod def setUpTestData(cls): - course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") - # Users... - cls.global_staff = User.objects.create_user( - 'global_staff', email='gstaff@example.com', is_staff=True - ) - cls.student = User.objects.create_user( - 'student', email='student@example.com', is_staff=False - ) - cls.beta_tester = BetaTesterFactory(course_key=course_key) - cls.anonymous_user = AnonymousUser() - - cls.course_key = course_key + super().setUpTestData() # The UsageKeys we're going to set up for date tests. cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1') @@ -215,13 +504,13 @@ class ScheduleTestCase(CacheIsolationTestCase): cls.seq_inherit_key = cls.course_key.make_usage_key('sequential', 'seq_inherit') cls.seq_due_key = cls.course_key.make_usage_key('sequential', 'seq_due') - cls.all_seq_keys = [ + cls.set_sequence_keys([ cls.seq_before_key, cls.seq_same_key, cls.seq_after_key, cls.seq_inherit_key, cls.seq_due_key, - ] + ]) # Set scheduling information into edx-when for a single Section with # sequences starting at various times. @@ -275,7 +564,9 @@ class ScheduleTestCase(CacheIsolationTestCase): title="User Outline Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, + self_paced=False, course_visibility=CourseVisibility.PRIVATE, sections=[ CourseSectionData( @@ -311,7 +602,6 @@ class ScheduleTestCase(CacheIsolationTestCase): ] ) ], - self_paced=False, ) replace_course_outline(cls.outline) @@ -322,19 +612,6 @@ class ScheduleTestCase(CacheIsolationTestCase): assert user_has_role(cls.beta_tester, CourseBetaTesterRole(cls.course_key)) assert cls.outline.days_early_for_beta is None - def get_details(self, at_time): - staff_details = get_user_course_outline_details(self.course_key, self.global_staff, at_time) - student_details = get_user_course_outline_details(self.course_key, self.student, at_time) - beta_tester_details = get_user_course_outline_details(self.course_key, self.beta_tester, at_time) - return staff_details, student_details, beta_tester_details - - def get_sequence_keys(self, exclude=None): - if exclude is None: - exclude = [] - if not isinstance(exclude, list): - raise TypeError("`exclude` must be a list of keys to be excluded") - return [key for key in self.all_seq_keys if key not in exclude] - def test_before_course_starts(self): staff_details, student_details, beta_tester_details = self.get_details( datetime(2020, 5, 9, tzinfo=timezone.utc) @@ -486,27 +763,21 @@ class ScheduleTestCase(CacheIsolationTestCase): assert len(beta_tester_details.outline.accessible_sequences) == 4 -class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase): +class SelfPacedTestCase(OutlineProcessorTestCase): + @classmethod def setUpTestData(cls): - # Users... - cls.global_staff = User.objects.create_user( - 'global_staff', email='gstaff@example.com', is_staff=True - ) - cls.student = User.objects.create_user( - 'student', email='student@example.com', is_staff=False - ) - - cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") + super().setUpTestData() # The UsageKeys we're going to set up for date tests. cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1') + cls.seq_one_key = cls.course_key.make_usage_key('sequential', 'seq_one_key') + cls.seq_two_key = cls.course_key.make_usage_key('sequential', 'seq_two_key') - # Sequence with due date - cls.seq_due_key = cls.course_key.make_usage_key('sequential', 'seq') - - # Sequence with due date and "inaccessible after due" enabled - cls.seq_hide_after_due_key = cls.course_key.make_usage_key('sequential', 'seq_hide_after_due_key') + cls.set_sequence_keys([ + cls.seq_one_key, + cls.seq_two_key, + ]) # Set scheduling information into edx-when for a single Section with # two sequences with due date @@ -524,11 +795,11 @@ class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase): {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} ), ( - cls.seq_due_key, + cls.seq_one_key, {'due': datetime(2020, 5, 21, tzinfo=timezone.utc)} ), ( - cls.seq_hide_after_due_key, + cls.seq_two_key, {'due': datetime(2020, 5, 21, tzinfo=timezone.utc)} ), ] @@ -542,6 +813,7 @@ class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase): title="User Outline Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, course_visibility=CourseVisibility.PRIVATE, sections=[ @@ -551,15 +823,14 @@ class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase): visibility=visibility, sequences=[ CourseLearningSequenceData( - usage_key=cls.seq_due_key, - title='Due', + usage_key=cls.seq_one_key, + title='Sequence One', visibility=visibility ), CourseLearningSequenceData( - usage_key=cls.seq_hide_after_due_key, - title='Inaccessible after due', - visibility=visibility, - inaccessible_after_due=True + usage_key=cls.seq_two_key, + title='Sequence Two', + visibility=visibility ), ], ), @@ -574,35 +845,214 @@ class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase): def test_sequences_accessible_after_due(self): at_time = datetime(2020, 5, 22, tzinfo=timezone.utc) - staff_outline = get_user_course_outline_details(self.course_key, self.global_staff, at_time).outline - student_outline = get_user_course_outline_details(self.course_key, self.student, at_time).outline + + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) # Staff can always access all sequences - assert len(staff_outline.accessible_sequences) == 2 + assert len(staff_details.outline.accessible_sequences) == 2 # In self-paced course, due date of sequences equals to due date of # course, so here student should see all sequences, even if their # due dates explicitly were set before end of course - assert len(student_outline.accessible_sequences) == 2 + assert len(student_details.outline.accessible_sequences) == 2 -class VisbilityTestCase(CacheIsolationTestCase): +class SpecialExamsTestCase(OutlineProcessorTestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + # The UsageKeys we're going to set up for date tests. + cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1') + cls.seq_practice_exam_key = cls.course_key.make_usage_key('sequential', 'seq_practice_exam_key') + cls.seq_proctored_exam_key = cls.course_key.make_usage_key('sequential', 'seq_proctored_exam_key') + cls.seq_timed_exam_key = cls.course_key.make_usage_key('sequential', 'seq_timed_exam_key') + cls.seq_normal_key = cls.course_key.make_usage_key('sequential', 'seq_normal_key') + + cls.set_sequence_keys([ + cls.seq_practice_exam_key, + cls.seq_proctored_exam_key, + cls.seq_timed_exam_key, + cls.seq_normal_key, + ]) + + # Set scheduling information into edx-when for a single Section with + # two sequences with due date + set_dates_for_course( + cls.course_key, + [ + ( + cls.course_key.make_usage_key('course', 'course'), + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.section_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.seq_practice_exam_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.seq_proctored_exam_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.seq_timed_exam_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ( + cls.seq_normal_key, + {'start': datetime(2020, 5, 15, tzinfo=timezone.utc)} + ), + ] + ) + visibility = VisibilityData( + hide_from_toc=False, + visible_to_staff_only=False + ) + cls.outline = CourseOutlineData( + course_key=cls.course_key, + title="User Outline Test Course!", + published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), + published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, + days_early_for_beta=None, + course_visibility=CourseVisibility.PRIVATE, + sections=[ + CourseSectionData( + usage_key=cls.section_key, + title="Section", + visibility=visibility, + sequences=[ + CourseLearningSequenceData( + usage_key=cls.seq_practice_exam_key, + title='Exam', + visibility=visibility, + exam=ExamData(is_practice_exam=True) + ), + CourseLearningSequenceData( + usage_key=cls.seq_proctored_exam_key, + title='Exam', + visibility=visibility, + exam=ExamData(is_proctored_enabled=True) + ), + CourseLearningSequenceData( + usage_key=cls.seq_timed_exam_key, + title='Exam', + visibility=visibility, + exam=ExamData(is_time_limited=True) + ), + CourseLearningSequenceData( + usage_key=cls.seq_normal_key, + title='Normal', + visibility=visibility + ), + ], + ), + ], + self_paced=True, + ) + + replace_course_outline(cls.outline) + + # Enroll student in the course + cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") + + @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': True}) + def test_special_exams_enabled_all_sequences_visible(self): + at_time = datetime(2020, 5, 22, tzinfo=timezone.utc) + + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 4 + assert len(staff_details.outline.sequences) == 4 + + # Ensure the exams are all still present + assert len(student_details.outline.accessible_sequences) == 4 + assert len(student_details.outline.sequences) == 4 + + @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': False}) + def test_special_exams_disabled_preserves_exam_sequences(self): + at_time = datetime(2020, 5, 22, tzinfo=timezone.utc) + + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Staff can always access all sequences + assert len(staff_details.outline.accessible_sequences) == 4 + assert len(staff_details.outline.sequences) == 4 + + # Ensure the exams have been completely removed + assert len(student_details.outline.accessible_sequences) == 4 + assert len(student_details.outline.sequences) == 4 + for key in self.get_sequence_keys(exclude=[self.seq_normal_key]): + assert key in student_details.outline.accessible_sequences + assert key in student_details.outline.sequences + + @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_in_details(self, mock_get_attempt_status_summary): + at_time = datetime(2020, 5, 22, tzinfo=timezone.utc) + + def get_attempt_status_side_effect(user_id, _course_key, usage_key): + """ + Returns fake data for calls to get_attempt_status_summary for the student + """ + if user_id != self.student.id: + raise ProctoredExamNotFoundException + + for sequence_key in self.get_sequence_keys(exclude=[self.seq_normal_key]): + if usage_key == str(sequence_key): + num_fake_attempts = mock_get_attempt_status_summary.call_count % len(self.all_seq_keys) + return { + "summary": { + "usage_key": usage_key + } + } + + mock_get_attempt_status_summary.side_effect = get_attempt_status_side_effect + + _, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + assert len(student_details.special_exam_attempts.sequences) == 3 + for sequence_key in self.get_sequence_keys(exclude=[self.seq_normal_key]): + assert sequence_key in student_details.special_exam_attempts.sequences + attempt_summary = student_details.special_exam_attempts.sequences[sequence_key] + assert type(attempt_summary) == dict + assert attempt_summary["summary"]["usage_key"] == str(sequence_key) + + @patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': False}) + @patch('openedx.core.djangoapps.content.learning_sequences.api.processors.special_exams.get_attempt_status_summary') + def test_special_exam_attempt_data_empty_when_disabled(self, mock_get_attempt_status_summary): + at_time = datetime(2020, 5, 22, tzinfo=timezone.utc) + + _, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) + + # Ensure that no calls are made to get_attempt_status_summary and no data in special_exam_attempts + assert mock_get_attempt_status_summary.call_count == 0 + assert len(student_details.special_exam_attempts.sequences) == 0 + + +class VisbilityTestCase(OutlineProcessorTestCase): """ Visibility-related tests. """ @classmethod def setUpTestData(cls): - # Users... - cls.global_staff = User.objects.create_user( - 'global_staff', email='gstaff@example.com', is_staff=True - ) - cls.student = User.objects.create_user( - 'student', email='student@example.com', is_staff=False - ) - cls.anonymous_user = AnonymousUser() - - cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") + super().setUpTestData() # The UsageKeys we're going to set up for date tests. cls.normal_section_key = cls.course_key.make_usage_key('chapter', 'normal_section') @@ -614,6 +1064,14 @@ class VisbilityTestCase(CacheIsolationTestCase): cls.normal_in_normal_key = cls.course_key.make_usage_key('sequential', 'normal_in_normal') cls.normal_in_staff_key = cls.course_key.make_usage_key('sequential', 'normal_in_staff') + cls.set_sequence_keys([ + cls.staff_in_normal_key, + cls.hide_in_normal_key, + cls.due_in_normal_key, + cls.normal_in_normal_key, + cls.normal_in_staff_key, + ]) + v_normal = VisibilityData( hide_from_toc=False, visible_to_staff_only=False @@ -632,6 +1090,7 @@ class VisbilityTestCase(CacheIsolationTestCase): title="User Outline Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, course_visibility=CourseVisibility.PRIVATE, sections=[ @@ -672,17 +1131,19 @@ class VisbilityTestCase(CacheIsolationTestCase): def test_visibility(self): at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) # Exact value doesn't matter - staff_outline = get_user_course_outline_details(self.course_key, self.global_staff, at_time).outline - student_outline = get_user_course_outline_details(self.course_key, self.student, at_time).outline + + staff_details, student_details, _ = self.get_details( + datetime(2020, 5, 25, tzinfo=timezone.utc) + ) # Sections visible - assert len(staff_outline.sections) == 2 - assert len(student_outline.sections) == 1 + assert len(staff_details.outline.sections) == 2 + assert len(student_details.outline.sections) == 1 # Sequences visible - assert len(staff_outline.sequences) == 4 - assert len(student_outline.sequences) == 1 - assert self.normal_in_normal_key in student_outline.sequences + assert len(staff_details.outline.sequences) == 4 + assert len(student_details.outline.sequences) == 1 + assert self.normal_in_normal_key in student_details.outline.sequences class SequentialVisibilityTestCase(CacheIsolationTestCase): @@ -715,6 +1176,7 @@ class SequentialVisibilityTestCase(CacheIsolationTestCase): title="User Outline Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, sections=generate_sections(cls.course_key, [2, 1, 3]), self_paced=False, diff --git a/openedx/core/djangoapps/content/learning_sequences/data.py b/openedx/core/djangoapps/content/learning_sequences/data.py index a12071a2c5..902f39b016 100644 --- a/openedx/core/djangoapps/content/learning_sequences/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/data.py @@ -68,6 +68,19 @@ class VisibilityData: visible_to_staff_only = attr.ib(type=bool) +@attr.s(frozen=True) +class ExamData: + """ + XBlock attributes that describe exams + """ + is_practice_exam = attr.ib(type=bool, default=False) + is_proctored_enabled = attr.ib(type=bool, default=False) + is_time_limited = attr.ib(type=bool, default=False) + + def __bool__(self): + return self.is_practice_exam or self.is_proctored_enabled or self.is_time_limited + + @attr.s(frozen=True) class CourseLearningSequenceData: """ @@ -82,6 +95,7 @@ class CourseLearningSequenceData: title = attr.ib(type=str) visibility = attr.ib(type=VisibilityData) + exam = attr.ib(type=ExamData, default=ExamData()) inaccessible_after_due = attr.ib(type=bool, default=True) @@ -147,6 +161,9 @@ class CourseOutlineData: course_visibility = attr.ib(validator=attr.validators.in_(CourseVisibility)) + # Entrance Exam ID + entrance_exam_id = attr.ib(type=str) + def __attrs_post_init__(self): """Post-init hook that validates and inits the `sequences` field.""" sequences = {} @@ -241,6 +258,14 @@ class ScheduleData: sequences = attr.ib(type=Dict[UsageKey, ScheduleItemData]) +@attr.s(frozen=True) +class SpecialExamAttemptData: + """ + Overall special exam attempt data. + """ + sequences = attr.ib(type=Dict[UsageKey, Dict]) + + @attr.s(frozen=True) class UserCourseOutlineData(CourseOutlineData): """ @@ -288,3 +313,4 @@ class UserCourseOutlineDetailsData: """ outline = attr.ib(type=UserCourseOutlineData) schedule = attr.ib(type=ScheduleData) + special_exam_attempts = attr.ib(type=SpecialExamAttemptData) diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0006_coursecontext_entrance_exam_id.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0006_coursecontext_entrance_exam_id.py new file mode 100644 index 0000000000..3bd50720a4 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0006_coursecontext_entrance_exam_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.14 on 2020-07-20 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0005_coursecontext_days_early_for_beta'), + ] + + operations = [ + migrations.AddField( + model_name='coursecontext', + name='entrance_exam_id', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0007_coursesequenceexam.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0007_coursesequenceexam.py new file mode 100644 index 0000000000..a3081bae0a --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0007_coursesequenceexam.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.16 on 2020-09-30 07:14 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0006_coursecontext_entrance_exam_id'), + ] + + operations = [ + migrations.CreateModel( + name='CourseSequenceExam', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('is_practice_exam', models.BooleanField(default=False)), + ('is_proctored_enabled', models.BooleanField(default=False)), + ('is_time_limited', models.BooleanField(default=False)), + ('course_section_sequence', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='exam', to='learning_sequences.CourseSectionSequence')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index 2fa574d2bb..e5e37dc151 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -82,6 +82,7 @@ class CourseContext(TimeStampedModel): ) days_early_for_beta = models.IntegerField(null=True, blank=True) self_paced = models.BooleanField(default=False) + entrance_exam_id = models.CharField(max_length=255, null=True) class LearningSequence(TimeStampedModel): @@ -196,3 +197,15 @@ class CourseSectionSequence(CourseContentVisibilityMixin, TimeStampedModel): unique_together = [ ['course_context', 'ordering'], ] + + +class CourseSequenceExam(TimeStampedModel): + """ + This model stores XBlock information that affects outline level information + pertaining to special exams + """ + course_section_sequence = models.OneToOneField(CourseSectionSequence, on_delete=models.CASCADE, related_name='exam') + + is_practice_exam = models.BooleanField(default=False) + is_proctored_enabled = models.BooleanField(default=False) + is_time_limited = models.BooleanField(default=False) diff --git a/openedx/core/djangoapps/content/learning_sequences/tasks.py b/openedx/core/djangoapps/content/learning_sequences/tasks.py index ef03a17bf4..320fc8663b 100644 --- a/openedx/core/djangoapps/content/learning_sequences/tasks.py +++ b/openedx/core/djangoapps/content/learning_sequences/tasks.py @@ -10,7 +10,11 @@ from xmodule.modulestore.django import modulestore from .api import replace_course_outline from .data import ( - CourseOutlineData, CourseSectionData, CourseLearningSequenceData, VisibilityData, + CourseOutlineData, + CourseSectionData, + CourseLearningSequenceData, + ExamData, + VisibilityData, CourseVisibility ) @@ -39,6 +43,11 @@ def get_outline_from_modulestore(course_key): usage_key=sequence.location, title=sequence.display_name, inaccessible_after_due=sequence.hide_after_due, + exam=ExamData( + is_practice_exam=sequence.is_practice_exam, + is_proctored_enabled=sequence.is_proctored_enabled, + is_time_limited=sequence.is_timed_exam + ), visibility=VisibilityData( hide_from_toc=sequence.hide_from_toc, visible_to_staff_only=sequence.visible_to_staff_only @@ -71,6 +80,7 @@ def get_outline_from_modulestore(course_key): title=course.display_name, published_at=course.subtree_edited_on, published_version=str(course.course_version), # .course_version is a BSON obj + entrance_exam_id=course.entrance_exam_id, days_early_for_beta=course.days_early_for_beta, sections=sections_data, self_paced=course.self_paced, diff --git a/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py b/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py index cbcfe97199..7bac8f10f4 100644 --- a/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py +++ b/openedx/core/djangoapps/content/learning_sequences/tests/test_views.py @@ -44,6 +44,7 @@ class CourseOutlineViewTest(CacheIsolationTestCase, APITestCase): title="Views Test Course!", published_at=datetime(2020, 5, 20, tzinfo=timezone.utc), published_version="5ebece4b69dd593d82fe2020", + entrance_exam_id=None, days_early_for_beta=None, sections=generate_sections(cls.course_key, [2, 2]), self_paced=False, diff --git a/openedx/core/djangoapps/content/learning_sequences/views.py b/openedx/core/djangoapps/content/learning_sequences/views.py index 69b33ec786..f39d8764d2 100644 --- a/openedx/core/djangoapps/content/learning_sequences/views.py +++ b/openedx/core/djangoapps/content/learning_sequences/views.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone import json import logging +from django.conf import settings from django.contrib.auth import get_user_model from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -62,6 +63,7 @@ class CourseOutlineView(APIView): """ user_course_outline = user_course_outline_details.outline schedule = user_course_outline_details.schedule + exam_information = user_course_outline_details.special_exam_attempts return { # Top level course information "course_key": str(user_course_outline.course_key), @@ -70,6 +72,7 @@ class CourseOutlineView(APIView): "title": user_course_outline.title, "published_at": user_course_outline.published_at, "published_version": user_course_outline.published_version, + "entrance_exam_id": user_course_outline.entrance_exam_id, "days_early_for_beta": user_course_outline.days_early_for_beta, "self_paced": user_course_outline.self_paced, @@ -89,6 +92,7 @@ class CourseOutlineView(APIView): str(seq_usage_key): self._sequence_repr( sequence, schedule.sequences.get(seq_usage_key), + exam_information.sequences.get(seq_usage_key, {}), user_course_outline.accessible_sequences, ) for seq_usage_key, sequence in user_course_outline.sequences.items() @@ -96,7 +100,7 @@ class CourseOutlineView(APIView): }, } - def _sequence_repr(self, sequence, sequence_schedule, accessible_sequences): + def _sequence_repr(self, sequence, sequence_schedule, sequence_exam, accessible_sequences): """Representation of a Sequence.""" if sequence_schedule is None: schedule_item_dict = {'start': None, 'effective_start': None, 'due': None} @@ -108,7 +112,7 @@ class CourseOutlineView(APIView): 'due': sequence_schedule.due, } - return { + sequence_representation = { "id": str(sequence.usage_key), "title": sequence.title, "accessible": sequence.usage_key in accessible_sequences, @@ -116,6 +120,12 @@ class CourseOutlineView(APIView): **schedule_item_dict, } + # Only include this data if special exams are on + if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False): + sequence_representation["exam"] = sequence_exam + + return sequence_representation + def _section_repr(self, section, section_schedule): """Representation of a Section.""" if section_schedule is None: