diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index 14238ed4ead2..5db6110619b6 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -66,6 +66,8 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring } if 'special_exam_info' in self.context.get('extra_fields', []) and block.get('special_exam_info'): serialized[block_key]['special_exam_info'] = block.get('special_exam_info').get('short_description') + if 'completion_stat' in self.context.get('extra_fields', []): + serialized[block_key]['completion_stat'] = block.get('completion_stat', {}) for child in children: serialized.update(self.get_blocks(child)) 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 1db4bdce3fd9..797cf4cca442 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -9,6 +9,7 @@ import ddt # lint-amnesty, pylint: disable=wrong-import-order import json # lint-amnesty, pylint: disable=wrong-import-order +from completion.models import BlockCompletion from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order @@ -489,7 +490,7 @@ def add_blocks_to_course(self): parent_location=self.chapter.location ) self.vertical = BlockFactory.create( - category='problem', + category='vertical', graded=True, has_score=True, parent_location=self.sequential.location @@ -500,11 +501,20 @@ def add_blocks_to_course(self): parent_location=self.chapter.location ) self.ungraded_vertical = BlockFactory.create( - category='problem', + category='vertical', parent_location=self.ungraded_sequential.location ) update_outline_from_modulestore(self.course.id) + def create_completion(self, problem, completion): + return BlockCompletion.objects.create( + user=self.user, + context_key=problem.context_key, + block_type='problem', + block_key=problem.location, + completion=completion, + ) + @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) def test_get_authenticated_enrolled_user(self, enrollment_mode): """ @@ -594,6 +604,18 @@ def test_proctored_exam(self, mock_summary): hide_after_due=False, is_onboarding_exam=False, ) + vertical = BlockFactory.create( + parent=sequence, + category='vertical', + graded=True, + has_score=True, + ) + BlockFactory.create( + parent=vertical, + category='problem', + graded=True, + has_score=True, + ) sequence.is_proctored_exam = True update_outline_from_modulestore(course.id) CourseEnrollment.enroll(self.user, course.id) @@ -608,7 +630,7 @@ def test_proctored_exam(self, mock_summary): exam_data = response.data['blocks'][str(sequence.location)] assert not exam_data['complete'] - assert exam_data['display_name'] == 'Test Proctored Exam' + assert exam_data['display_name'] == 'Test Proctored Exam (1 Question)' assert exam_data['special_exam_info'] == 'My Exam' assert exam_data['due'] is not None @@ -623,8 +645,8 @@ def test_assignment(self): 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 exam_data['display_name'] == 'Test' + assert exam_data['icon'] is None assert str(self.vertical.location) in exam_data['children'] ungraded_data = response.data['blocks'][str(self.ungraded_sequential.location)] @@ -686,3 +708,91 @@ def test_hide_learning_sequences(self): replace_course_outline(new_learning_seq_outline) blocks = self.client.get(self.url).data['blocks'] assert seq_block_id not in blocks + + def test_empty_blocks_complete(self): + """ + Test that the API returns the correct complete state for empty blocks. + """ + self.add_blocks_to_course() + CourseEnrollment.enroll(self.user, self.course.id) + url = reverse('course-home:course-sidebar-blocks', args=[self.course.id]) + response = self.client.get(url) + assert response.status_code == 200 + + sequence_data = response.data['blocks'][str(self.sequential.location)] + vertical_data = response.data['blocks'][str(self.vertical.location)] + assert sequence_data['complete'] + assert vertical_data['complete'] + + @ddt.data(True, False) + def test_blocks_complete_with_problem(self, problem_complete): + self.add_blocks_to_course() + problem = BlockFactory.create(parent=self.vertical, category='problem', graded=True, has_score=True) + CourseEnrollment.enroll(self.user, self.course.id) + self.create_completion(problem, int(problem_complete)) + + response = self.client.get(reverse('course-home:course-sidebar-blocks', args=[self.course.id])) + + sequence_data = response.data['blocks'][str(self.sequential.location)] + vertical_data = response.data['blocks'][str(self.vertical.location)] + + assert sequence_data['complete'] == problem_complete + assert vertical_data['complete'] == problem_complete + + def test_blocks_completion_stat(self): + """ + Test that the API returns the correct completion statistics for the blocks. + """ + self.add_blocks_to_course() + completed_problem = BlockFactory.create(parent=self.vertical, category='problem', graded=True, has_score=True) + uncompleted_problem = BlockFactory.create(parent=self.vertical, category='problem', graded=True, has_score=True) + update_outline_from_modulestore(self.course.id) + CourseEnrollment.enroll(self.user, self.course.id) + self.create_completion(completed_problem, 1) + self.create_completion(uncompleted_problem, 0) + response = self.client.get(reverse('course-home:course-sidebar-blocks', args=[self.course.id])) + + expected_sequence_completion_stat = { + 'completion': 0, + 'completable_children': 1, + } + expected_vertical_completion_stat = { + 'completion': 1, + 'completable_children': 2, + } + sequence_data = response.data['blocks'][str(self.sequential.location)] + vertical_data = response.data['blocks'][str(self.vertical.location)] + + assert not sequence_data['complete'] + assert not vertical_data['complete'] + assert sequence_data['completion_stat'] == expected_sequence_completion_stat + assert vertical_data['completion_stat'] == expected_vertical_completion_stat + + def test_blocks_completion_stat_all_problem_completed(self): + """ + Test that the API returns the correct completion statistics for the blocks when all problems are completed. + """ + self.add_blocks_to_course() + problem1 = BlockFactory.create(parent=self.vertical, category='problem', graded=True, has_score=True) + problem2 = BlockFactory.create(parent=self.vertical, category='problem', graded=True, has_score=True) + update_outline_from_modulestore(self.course.id) + CourseEnrollment.enroll(self.user, self.course.id) + self.create_completion(problem1, 1) + self.create_completion(problem2, 1) + response = self.client.get(reverse('course-home:course-sidebar-blocks', args=[self.course.id])) + + expected_sequence_completion_stat = { + 'completion': 1, + 'completable_children': 1, + } + expected_vertical_completion_stat = { + 'completion': 2, + 'completable_children': 2, + } + sequence_data = response.data['blocks'][str(self.sequential.location)] + vertical_data = response.data['blocks'][str(self.vertical.location)] + + assert sequence_data['complete'] + assert vertical_data['complete'] + assert sequence_data['completion_stat'] == expected_sequence_completion_stat + assert vertical_data['completion_stat'] == expected_vertical_completion_stat diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index bea51910ba63..8bf6be9d01a4 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -459,9 +459,7 @@ def get(self, request, *args, **kwargs): ) if courseware_mfe_navigation_sidebar_blocks_caching_is_enabled(): course_blocks = cache.get(cache_key) - cached = course_blocks is not None else: - cached = False course_blocks = None if not course_blocks: @@ -474,15 +472,13 @@ def get(self, request, *args, **kwargs): cache.set(cache_key, course_blocks, self.COURSE_BLOCKS_CACHE_TIMEOUT) course_blocks = self.filter_unavailable_blocks(course_blocks, course_key) - - if cached: - # If the data was cached, we need to mark the blocks as complete or not complete. + if course_blocks: course_blocks = self.mark_complete_recursive(course_blocks) context = self.get_serializer_context() context.update({ 'include_vertical': True, - 'extra_fields': ['special_exam_info'], + 'extra_fields': ['special_exam_info', 'completion_stat'], 'enable_prerequisite_block_type': True, }) @@ -502,7 +498,7 @@ def filter_unavailable_blocks(self, course_blocks, course_key): for section_data in course_sections: section_data['children'] = self.get_available_sequences( user_course_outline, - section_data.get('children', []) + section_data.get('children', ['completion']) ) accessible_sequence_ids = {str(usage_key) for usage_key in user_course_outline.accessible_sequences} for sequence_data in section_data['children']: @@ -516,11 +512,43 @@ def mark_complete_recursive(self, block): """ if 'children' in block: block['children'] = [self.mark_complete_recursive(child) for child in block['children']] - block['complete'] = all(child['complete'] for child in block['children'] if child['type'] != 'discussion') + completable_children = self.get_completable_children(block) + block['complete'] = completable_children == [] or all(child['complete'] for child in completable_children) + block['completion_stat'] = self.get_block_completion_stat(block, completable_children) else: - block['complete'] = self.completions_dict.get(block['id'], False) + # If the block is a course, chapter, sequential, or vertical, without children, + # it should be completed by default. + completion = self.completions_dict.get(block['id'], 0) + block['complete'] = bool(completion) or block['type'] in ['course', 'chapter', 'sequential', 'vertical'] + block['completion_stat'] = self.get_block_completion_stat(block, completable_children=[]) + return block + def get_block_completion_stat(self, block, completable_children): + """ + Get the completion status of a block. + """ + block_type = block['type'] + + if block_type in ['course', 'chapter', 'sequential']: + completion = sum(child['complete'] for child in completable_children) + elif block_type == 'vertical': + completion = sum(child['completion_stat']['completion'] for child in completable_children) + else: + completion = self.completions_dict.get(block['id'], 0) + + return { + 'completion': completion, + 'completable_children': len(completable_children), + } + + @staticmethod + def get_completable_children(block): + """ + Get the completable children of a block. + """ + return [child for child in block.get('children', []) if child['type'] != 'discussion'] + @staticmethod def get_available_sections(user_course_outline, course_sections): """