diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index db6cbdf3a1ab..0d0bf4632cfb 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -19,7 +19,8 @@ class CourseBlockSerializer(serializers.Serializer): def get_blocks(self, block): # pylint: disable=missing-function-docstring block_key = block['id'] block_type = block['type'] - children = block.get('children', []) if block_type != 'sequential' else [] # Don't descend past sequential + last_parent_block_type = 'vertical' if self.context.get('include_vertical') else 'sequential' + children = block.get('children', []) if block_type != last_parent_block_type else [] description = block.get('format') display_name = block['display_name'] enable_links = self.context.get('enable_links') diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index eebaf19cf65b..4b854c2174f9 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -447,3 +447,236 @@ def test_cannot_enroll_if_full(self): self.update_course_and_overview() CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot! self.assert_can_enroll(False) + + +@ddt.ddt +class SidebarBlocksTestViews(BaseCourseHomeTests): + """ + Tests for the Course Sidebar Blocks API + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.chapter = '' + self.sequential = '' + self.vertical = '' + self.ungraded_sequential = '' + self.ungraded_vertical = '' + self.url = '' + + def setUp(self): + super().setUp() + self.url = reverse('course-home:course-sidebar-blocks', args=[self.course.id]) + + def update_course_and_overview(self): + """ + Update the course and course overview records. + """ + self.update_course(self.course, self.user.id) + CourseOverview.load_from_module_store(self.course.id) + + def add_blocks_to_course(self): + """ + Add test blocks to the self course. + """ + with self.store.bulk_operations(self.course.id): + self.chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + self.sequential = BlockFactory.create( + display_name='Test', + category='sequential', + graded=True, + has_score=True, + parent_location=self.chapter.location + ) + self.vertical = BlockFactory.create( + category='problem', + graded=True, + has_score=True, + parent_location=self.sequential.location + ) + self.ungraded_sequential = BlockFactory.create( + display_name='Ungraded', + category='sequential', + parent_location=self.chapter.location + ) + self.ungraded_vertical = BlockFactory.create( + category='problem', + parent_location=self.ungraded_sequential.location + ) + update_outline_from_modulestore(self.course.id) + + @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) + def test_get_authenticated_enrolled_user(self, enrollment_mode): + """ + Test that the API returns the correct data for an authenticated, enrolled user. + """ + self.add_blocks_to_course() + CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode) + + response = self.client.get(self.url) + assert response.status_code == 200 + + chapter_data = response.data['blocks'][str(self.chapter.location)] + assert str(self.sequential.location) in chapter_data['children'] + + sequential_data = response.data['blocks'][str(self.sequential.location)] + assert str(self.vertical.location) in sequential_data['children'] + + vertical_data = response.data['blocks'][str(self.vertical.location)] + assert vertical_data['children'] == [] + + @ddt.data(True, False) + def test_get_authenticated_user_not_enrolled(self, has_previously_enrolled): + """ + Test that the API returns an empty response for an authenticated user who is not enrolled in the course. + """ + if has_previously_enrolled: + CourseEnrollment.enroll(self.user, self.course.id) + CourseEnrollment.unenroll(self.user, self.course.id) + + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data == {} + + def test_get_unauthenticated_user(self): + """ + Test that the API returns an empty response for an unauthenticated user. + """ + self.client.logout() + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data.get('blocks') is None + + def test_course_staff_can_see_non_user_specific_content_in_masquerade(self): + """ + Test that course staff can see the outline and other non-user-specific content when masquerading as a learner + """ + instructor = UserFactory(username='instructor', email='instructor@example.com', password='foo', is_staff=False) + CourseInstructorRole(self.course.id).add_users(instructor) + self.client.login(username=instructor, password='foo') + self.update_masquerade(role='student') + response = self.client.get(self.url) + assert response.data['blocks'] is not None + + def test_get_unknown_course(self): + """ + Test that the API returns a 404 when the course is not found. + """ + url = reverse('course-home:course-sidebar-blocks', args=['course-v1:unknown+course+2T2020']) + response = self.client.get(url) + assert response.status_code == 404 + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) + def test_proctored_exam(self): + """ + Test that the API returns the correct data for a proctored exam. + """ + course = CourseFactory.create( + org='edX', + course='900', + run='test_run', + enable_proctored_exams=True, + proctoring_provider=settings.PROCTORING_BACKENDS['DEFAULT'], + ) + chapter = BlockFactory.create(parent=course, category='chapter', display_name='Test Section') + sequence = BlockFactory.create( + parent=chapter, + category='sequential', + display_name='Test Proctored Exam', + graded=True, + is_time_limited=True, + default_time_limit_minutes=10, + is_practice_exam=True, + due=datetime.now(), + exam_review_rules='allow_use_of_paper', + hide_after_due=False, + is_onboarding_exam=False, + ) + sequence.is_proctored_exam = True + update_outline_from_modulestore(course.id) + CourseEnrollment.enroll(self.user, course.id) + + url = reverse('course-home:course-sidebar-blocks', args=[course.id]) + response = self.client.get(url) + assert response.status_code == 200 + + exam_data = response.data['blocks'][str(sequence.location)] + assert not exam_data['complete'] + assert exam_data['display_name'] == 'Test Proctored Exam' + assert exam_data['due'] is not None + + def test_assignment(self): + """ + Test that the API returns the correct data for an assignment. + """ + self.add_blocks_to_course() + CourseEnrollment.enroll(self.user, self.course.id) + + response = self.client.get(self.url) + assert response.status_code == 200 + + exam_data = response.data['blocks'][str(self.sequential.location)] + assert exam_data['display_name'] == 'Test (1 Question)' + assert exam_data['icon'] == 'fa-pencil-square-o' + assert str(self.vertical.location) in exam_data['children'] + + ungraded_data = response.data['blocks'][str(self.ungraded_sequential.location)] + assert ungraded_data['display_name'] == 'Ungraded' + assert ungraded_data['icon'] is None + assert str(self.ungraded_vertical.location) in ungraded_data['children'] + + @override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True) + @ddt.data(*itertools.product( + [True, False], [True, False], [None, COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE] + )) + @ddt.unpack + def test_visibility(self, is_enrolled, is_staff, course_visibility): + """ + Test that the API returns the correct data based on the user's enrollment status and the course's visibility. + """ + if is_enrolled: + CourseEnrollment.enroll(self.user, self.course.id) + if is_staff: + self.user.is_staff = True + self.user.save() + if course_visibility: + self.course.course_visibility = course_visibility + self.update_course_and_overview() + + show_enrolled = is_enrolled or is_staff + is_public = course_visibility == COURSE_VISIBILITY_PUBLIC + is_public_outline = course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE + + data = self.client.get(self.url).data + if not (show_enrolled or is_public or is_public_outline): + assert data == {} + else: + assert (data['blocks'] is not None) == (show_enrolled or is_public or is_public_outline) + + def test_hide_learning_sequences(self): + """ + Check that Learning Sequences filters out sequences. + """ + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + response = self.client.get(self.url) + assert response.status_code == 200 + + blocks = response.data['blocks'] + seq_block_id = next(block_id for block_id, block in blocks.items() if block['type'] == 'sequential') + + # With a course outline loaded, the same sequence is removed. + new_learning_seq_outline = CourseOutlineData( + course_key=self.course.id, + title='Test Course Outline!', + published_at=datetime(2021, 6, 14, tzinfo=timezone.utc), + published_version='5ebece4b69dd593d82fe2022', + entrance_exam_id=None, + days_early_for_beta=None, + sections=[], + self_paced=False, + course_visibility=CourseVisibility.PRIVATE + ) + replace_course_outline(new_learning_seq_outline) + blocks = self.client.get(self.url).data['blocks'] + assert seq_block_id not in blocks diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 91c8a6d7f15b..07c1aeb0e96e 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -28,6 +28,7 @@ ) from lms.djangoapps.course_goals.models import CourseGoal from lms.djangoapps.course_home_api.outline.serializers import ( + CourseBlockSerializer, OutlineTabSerializer, ) from lms.djangoapps.course_home_api.utils import get_course_or_403 @@ -375,6 +376,93 @@ def finalize_response(self, request, response, *args, **kwargs): return expose_header('Date', response) +class CourseSidebarBlocksView(RetrieveAPIView): + """ + **Use Cases** + Request details for the sidebar navigation of the course. + **Example Requests** + GET api/course_home/v1/sidebar/{course_key} + **Response Values** + For a good 200 response, the response will include: + blocks: List of serialized Course Block objects. Each serialization has the following fields: + id: (str) The usage ID of the block. + type: (str) The type of block. Possible values the names of any + XBlock type in the system, including custom blocks. Examples are + course, chapter, sequential, vertical, html, problem, video, and + discussion. + display_name: (str) The display name of the block. + lms_web_url: (str) The URL to the navigational container of the + xBlock on the web LMS. + children: (list) If the block has child blocks, a list of IDs of + the child blocks. + resume_block: (bool) Whether the block is the resume block + has_scheduled_content: (bool) Whether the block has more content scheduled for the future + **Returns** + * 200 on success. + * 403 if the user does not currently have access to the course and should be redirected. + * 404 if the course is not available or cannot be seen. + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + + serializer_class = CourseBlockSerializer + + def get(self, request, *args, **kwargs): + """ + Get the visible course blocks (from course to vertical types) for the given course. + """ + course_key_string = kwargs.get('course_key_string') + course_key = CourseKey.from_string(course_key_string) + course = get_course_or_403(request.user, 'load', course_key, check_if_enrolled=False) + + masquerade_object, request.user = setup_masquerade( + request, + course_key, + staff_access=has_access(request.user, 'staff', course_key), + reset_masquerade_data=True, + ) + + user_is_masquerading = is_masquerading(request.user, course_key, course_masquerade=masquerade_object) + + enrollment = CourseEnrollment.get_enrollment(request.user, course_key) + allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key) + allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC + allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE + course_blocks = None + + is_staff = bool(has_access(request.user, 'staff', course_key)) + if getattr(enrollment, 'is_active', False) or is_staff: + course_blocks = get_course_outline_block_tree(request, course_key_string, request.user) + elif allow_public_outline or allow_public or user_is_masquerading: + course_blocks = get_course_outline_block_tree(request, course_key_string, None) + + if course_blocks: + user_course_outline = get_user_course_outline(course_key, request.user, datetime.now(tz=timezone.utc)) + available_section_ids = {str(section.usage_key) for section in user_course_outline.sections} + available_sequence_ids = {str(usage_key) for usage_key in user_course_outline.sequences} + + course_blocks['children'] = [ + chapter_data for chapter_data in course_blocks.get('children', []) + if chapter_data['id'] in available_section_ids + ] + + for chapter_data in course_blocks['children']: + chapter_data['children'] = [ + seq_data for seq_data in chapter_data['children'] + if (seq_data['id'] in available_sequence_ids or seq_data['type'] != 'sequential') + ] if 'children' in chapter_data else [] + + context = self.get_serializer_context() + context['include_vertical'] = True + serializer = self.get_serializer_class()(course_blocks, context=context) + + return Response(serializer.data) + + @api_view(['POST']) @permission_classes((IsAuthenticated,)) def dismiss_welcome_message(request): # pylint: disable=missing-function-docstring diff --git a/lms/djangoapps/course_home_api/urls.py b/lms/djangoapps/course_home_api/urls.py index b5ffc08481a7..62e356d34710 100644 --- a/lms/djangoapps/course_home_api/urls.py +++ b/lms/djangoapps/course_home_api/urls.py @@ -9,7 +9,11 @@ from lms.djangoapps.course_home_api.course_metadata.views import CourseHomeMetadataView from lms.djangoapps.course_home_api.dates.views import DatesTabView from lms.djangoapps.course_home_api.outline.views import ( - OutlineTabView, dismiss_welcome_message, save_course_goal, unsubscribe_from_course_goal_by_token, + CourseSidebarBlocksView, + OutlineTabView, + dismiss_welcome_message, + save_course_goal, + unsubscribe_from_course_goal_by_token, ) from lms.djangoapps.course_home_api.progress.views import ProgressTabView @@ -44,6 +48,11 @@ OutlineTabView.as_view(), name='outline-tab' ), + re_path( + fr'sidebar/{settings.COURSE_KEY_PATTERN}', + CourseSidebarBlocksView.as_view(), + name='course-sidebar-blocks' + ), re_path( r'dismiss_welcome_message', dismiss_welcome_message,