From cdd2d7e71a0451801503cedfb27713169093c3f0 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Mon, 13 May 2024 15:38:06 +0300 Subject: [PATCH] feat: [AXM-287,310,331] Change course progress calculation logic (#2553) * feat: [AXM-287,310,331] Change course progress calculation logic * style: [AXM-287,310,331] Remove commented code * fix: [AXM-287,310,331] Change course assignments gather logic --- .../course_api/blocks/tests/test_views.py | 8 +- lms/djangoapps/courseware/courses.py | 8 +- .../mobile_api/course_info/serializers.py | 27 +++- .../mobile_api/course_info/views.py | 7 +- .../tests/test_course_info_serializers.py | 37 ++++- .../mobile_api/users/serializers.py | 49 +++--- lms/djangoapps/mobile_api/users/tests.py | 144 +++++++++++++++--- lms/djangoapps/mobile_api/users/views.py | 4 + 8 files changed, 226 insertions(+), 58 deletions(-) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index c1f673652096..4b9823328114 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -3,7 +3,7 @@ """ from datetime import datetime from unittest import mock -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from urllib.parse import urlencode, urlunparse import ddt @@ -209,8 +209,9 @@ def test_not_authenticated_public_course_with_all_blocks(self): self.query_params['all_blocks'] = True self.verify_response(403) + @mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) @mock.patch("lms.djangoapps.course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) - def test_not_authenticated_public_course_with_blank_username(self): + def test_not_authenticated_public_course_with_blank_username(self, get_course_assignment_mock: MagicMock) -> None: """ Verify behaviour when accessing course blocks of a public course for anonymous user anonymously. """ @@ -368,7 +369,8 @@ def test_extra_field_when_not_requested(self): block_data['type'] == 'course' ) - def test_data_researcher_access(self): + @mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_data_researcher_access(self, get_course_assignment_mock: MagicMock) -> None: """ Test if data researcher has access to the api endpoint """ diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 2fc727623541..ee0d12ce1a52 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -5,7 +5,7 @@ import logging from collections import defaultdict, namedtuple -from datetime import datetime +from datetime import datetime, timedelta import six import pytz @@ -587,7 +587,7 @@ def get_course_blocks_completion_summary(course_key, user): @request_cached() -def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements +def get_course_assignments(course_key, user, include_access=False, include_without_due=False,): # lint-amnesty, pylint: disable=too-many-statements """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. @@ -607,6 +607,10 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne for subsection_key in block_data.get_children(section_key): due = block_data.get_xblock_field(subsection_key, 'due') graded = block_data.get_xblock_field(subsection_key, 'graded', False) + + if not due and include_without_due: + due = now + timedelta(days=1000) + if due and graded: first_component_block_id = get_first_component_of_block(subsection_key, block_data) contains_gated_content = include_access and block_data.get_xblock_field( diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index d7a9471088aa..d5ad89eb69a5 100644 --- a/lms/djangoapps/mobile_api/course_info/serializers.py +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -2,7 +2,7 @@ Course Info serializers """ from rest_framework import serializers -from typing import Union +from typing import Dict, Union from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -13,6 +13,7 @@ from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user from lms.djangoapps.courseware.access_utils import check_course_open_for_learner +from lms.djangoapps.courseware.courses import get_course_assignments from lms.djangoapps.mobile_api.users.serializers import ModeSerializer from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_duration_limits.access import get_user_course_expiration_date @@ -31,6 +32,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): course_sharing_utm_parameters = serializers.SerializerMethodField() course_about = serializers.SerializerMethodField('get_course_about_url') course_modes = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() class Meta: model = CourseOverview @@ -47,6 +49,7 @@ class Meta: 'course_sharing_utm_parameters', 'course_about', 'course_modes', + 'course_progress', ) @staticmethod @@ -75,6 +78,28 @@ def get_course_modes(self, course_overview): for mode in course_modes ] + def get_course_progress(self, obj: 'CourseOverview') -> Dict[str, int]: # noqa: F821 #here + """ + Gets course progress calculated by course assignments. + """ + course_assignments = get_course_assignments( + obj.id, + self.context.get('user'), + include_without_due=True, + ) + + total_assignments_count = 0 + assignments_completed = 0 + + if course_assignments: + total_assignments_count = len(course_assignments) + assignments_completed = len([assignment for assignment in course_assignments if assignment.complete]) + + return { + 'total_assignments_count': total_assignments_count, + 'assignments_completed': assignments_completed, + } + class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): """ diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index c6de108727d8..40d586839680 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -271,6 +271,11 @@ class BlocksInfoInCourseView(BlocksInCourseView): course, chapter, sequential, vertical, html, problem, video, and discussion. display_name: (str) The display name of the block. + course_progress: (dict) Contains information about how many assignments are in the course + and how many assignments the student has completed. + Included here: + * total_assignments_count: (int) Total course's assignments count. + * assignments_completed: (int) Assignments witch the student has completed. **Returns** @@ -366,7 +371,7 @@ def list(self, request, **kwargs): # pylint: disable=W0221 ) course_info_context = { - 'user': requested_user + 'user': requested_user, } user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key) course_data.update({ diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py index 51d9acba54cc..c18d22f0ae99 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py @@ -147,7 +147,8 @@ def setUp(self): self.user = UserFactory() self.course_overview = CourseOverviewFactory() - def test_get_media(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_media(self, get_course_assignment_mock: MagicMock) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertIn('media', output_data) @@ -156,16 +157,46 @@ def test_get_media(self): self.assertIn('small', output_data['media']['image']) self.assertIn('large', output_data['media']['image']) + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) @patch('lms.djangoapps.mobile_api.course_info.serializers.get_link_for_about_page', return_value='mock_about_link') - def test_get_course_sharing_utm_parameters(self, mock_get_link_for_about_page: MagicMock) -> None: + def test_get_course_sharing_utm_parameters( + self, + mock_get_link_for_about_page: MagicMock, + get_course_assignment_mock: MagicMock, + ) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertEqual(output_data['course_about'], mock_get_link_for_about_page.return_value) mock_get_link_for_about_page.assert_called_once_with(self.course_overview) - def test_get_course_modes(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_course_modes(self, get_course_assignment_mock: MagicMock) -> None: expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}] output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertListEqual(output_data['course_modes'], expected_course_modes) + + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_course_progress_no_assignments(self, get_course_assignment_mock: MagicMock) -> None: + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True) + + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments') + def test_get_course_progress_with_assignments(self, get_course_assignment_mock: MagicMock) -> None: + assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 82389199ef72..dd82d9f99e49 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -5,7 +5,6 @@ from datetime import datetime from typing import Dict, List, Optional, Tuple, Union -from django.core.cache import cache from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from opaque_keys import InvalidKeyError @@ -19,10 +18,9 @@ from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc -from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks +from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks, get_course_assignments from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer -from lms.djangoapps.grades.api import CourseGradeFactory -from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager +from lms.djangoapps.mobile_api.utils import API_V4 from openedx.features.course_duration_limits.access import get_user_course_expiration_date from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -107,8 +105,6 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): audit_access_expires = serializers.SerializerMethodField() course_modes = serializers.SerializerMethodField() - BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour - def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null @@ -140,38 +136,37 @@ def get_course_modes(self, obj): for mode in course_modes ] - def to_representation(self, instance): + def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # noqa: F821 """ Override the to_representation method to add the course_status field to the serialized data. """ data = super().to_representation(instance) - if 'progress' in self.context.get('requested_fields', []): - data['progress'] = self.calculate_progress(instance) + + if 'course_progress' in self.context.get('requested_fields', []) and self.context.get('api_version') == API_V4: + data['course_progress'] = self.calculate_progress(instance) return data def calculate_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ Calculate the progress of the user in the course. - :param model: - :return: """ - is_staff = bool(has_access(model.user, 'staff', model.course.id)) + course_assignments = get_course_assignments( + model.course_id, + model.user, + include_without_due=True, + ) - cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}' - collected_block_structure = cache.get(cache_key) - if not collected_block_structure: - collected_block_structure = get_block_structure_manager(model.course.id).get_collected() - cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT) + total_assignments_count = 0 + assignments_completed = 0 - course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure) + if course_assignments: + total_assignments_count = len(course_assignments) + assignments_completed = len([assignment for assignment in course_assignments if assignment.complete]) - # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) - course_grade.update(visible_grades_only=True, has_staff_access=is_staff) - subsection_grades = list(course_grade.subsection_grades.values()) return { - 'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)), - 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), + 'total_assignments_count': total_assignments_count, + 'assignments_completed': assignments_completed, } class Meta: @@ -199,7 +194,7 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): """ course_status = serializers.SerializerMethodField() - progress = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() course_assignments = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): @@ -213,7 +208,7 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[ try: block_id = str(get_key_to_last_completed_block(model.user, model.course.id)) except UnavailableCompletionData: - block_id = "" + block_id = '' if not block_id: return None @@ -251,7 +246,7 @@ def _get_last_visited_block_path_and_unit_name( return path, vertical.display_name - def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: + def get_course_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ Returns the progress of the user in the course. """ @@ -302,7 +297,7 @@ class Meta: 'certificate', 'course_modes', 'course_status', - 'progress', + 'course_progress', 'course_assignments', ) lookup_field = 'username' diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 05a75f30f5c3..71199db1b0fe 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -4,7 +4,7 @@ import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from urllib.parse import parse_qs import ddt @@ -436,8 +436,7 @@ def test_student_dont_have_enrollments(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_one_enrollment(self, cache_mock: MagicMock): + def test_student_have_one_enrollment(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -461,8 +460,7 @@ def test_student_have_one_enrollment(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(str(course.id), response.data['primary']['course']['id']) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_two_enrollments(self, cache_mock: MagicMock): + def test_student_have_two_enrollments(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -481,8 +479,7 @@ def test_student_have_two_enrollments(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_more_then_ten_enrollments(self, cache_mock: MagicMock): + def test_student_have_more_then_ten_enrollments(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -502,8 +499,7 @@ def test_student_have_more_then_ten_enrollments(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_progress_in_old_course_and_enroll_newest_course(self, cache_mock: MagicMock): + def test_student_have_progress_in_old_course_and_enroll_newest_course(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -583,8 +579,7 @@ def test_student_enrolled_only_not_mobile_available_courses(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_do_progress_in_not_mobile_available_course(self, cache_mock: MagicMock): + def test_do_progress_in_not_mobile_available_course(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -621,8 +616,7 @@ def test_do_progress_in_not_mobile_available_course(self, cache_mock: MagicMock) self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_pagination_for_user_enrollments_api_v4(self, cache_mock: MagicMock): + def test_pagination_for_user_enrollments_api_v4(self): """ Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. """ @@ -641,8 +635,7 @@ def test_pagination_for_user_enrollments_api_v4(self, cache_mock: MagicMock): self.assertIn('previous', response.data['enrollments']) self.assertIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self, cache_mock: MagicMock): + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -655,12 +648,10 @@ def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self, ca self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['primary']['course_status'], None) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) @patch('lms.djangoapps.mobile_api.users.serializers.get_key_to_last_completed_block') def test_course_status_in_primary_obj_when_student_have_progress( self, get_last_completed_block_mock: MagicMock, - cache_mock: MagicMock ): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. @@ -707,8 +698,7 @@ def test_course_status_in_primary_obj_when_student_have_progress( self.assertEqual(response.data['primary']['course_status'], expected_course_status) get_last_completed_block_mock.assert_called_once_with(self.user, course.id) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_user_enrollment_api_v4_in_progress_status(self, cache_mock: MagicMock): + def test_user_enrollment_api_v4_in_progress_status(self): """ Testing """ @@ -902,8 +892,7 @@ def test_user_enrollment_api_v4_status_all(self): self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_response_contains_primary_enrollment_assignments_info(self, cache_mock: MagicMock): + def test_response_contains_primary_enrollment_assignments_info(self): self.login() course = CourseFactory.create(org='edx', mobile_available=True) self.enroll(course.id) @@ -917,6 +906,119 @@ def test_response_contains_primary_enrollment_assignments_info(self, cache_mock: self.assertListEqual(response.data['primary']['course_assignments']['past_assignments'], []) self.assertListEqual(response.data['primary']['course_assignments']['future_assignments'], []) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments', return_value=[]) + def test_course_progress_in_primary_enrollment_with_no_assignments( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_in_primary_enrollment_with_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_no_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertNotIn('course_progress', enrollment) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertIn('course_progress', enrollment) + self.assertDictEqual(enrollment['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param_and_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(2)] + for course in courses: + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + self.assertIn('course_progress', response.data['enrollments']['results'][0]) + self.assertDictEqual(response.data['enrollments']['results'][0]['course_progress'], expected_course_progress) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index b8949d269632..c9e9f725e7db 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -326,6 +326,10 @@ class UserCourseEnrollmentsList(generics.ListAPIView): * mode: The type of certificate registration for this course (honor or certified). * url: URL to the downloadable version of the certificate, if exists. + * course_progress: Contains information about how many assignments are in the course + and how many assignments the student has completed. + * total_assignments_count: Total course's assignments count. + * assignments_completed: Assignments witch the student has completed. """ lookup_field = 'username'