From b89485f362f30de8480ff874d8852f628cf3e89f Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:01:30 +0200 Subject: [PATCH] feat: [AXM-47] Add course_status field to primary object (#2517) --- .../mobile_api/users/serializers.py | 87 +++++++++++++++++ lms/djangoapps/mobile_api/users/tests.py | 94 +++++++++++++++++-- lms/djangoapps/mobile_api/users/views.py | 24 ++++- 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index d7005e5f68e7..944f3c36defd 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -2,7 +2,10 @@ Serializer for user API """ +from typing import Dict, List, Optional, Tuple +from completion.exceptions import UnavailableCompletionData +from completion.utilities import get_key_to_last_completed_block from rest_framework import serializers from rest_framework.reverse import reverse @@ -11,7 +14,11 @@ from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.courses import get_current_child +from lms.djangoapps.courseware.model_data import FieldDataCache from openedx.features.course_duration_limits.access import get_user_course_expiration_date +from xmodule.modulestore.django import modulestore class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: disable=abstract-method @@ -141,6 +148,86 @@ class Meta: lookup_field = 'username' +class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): + """ + Serializes CourseEnrollment models for API v4. + + Adds `course_status` field into serializer data. + """ + course_status = serializers.SerializerMethodField() + + def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: + """ + Gets course status for the given user's enrollments. + """ + try: + block_id = str(get_key_to_last_completed_block(model.user, model.course.id)) + except UnavailableCompletionData: + block_id = "" + + if not block_id: + return None + + request = self.context.get('request') + path, unit_name = self._get_last_visited_block_path_and_unit_name(request, model) + path_ids = [str(block.location) for block in path] + + return { + 'last_visited_module_id': path_ids[0], + 'last_visited_module_path': path_ids, + 'last_visited_block_id': block_id, + 'last_visited_unit_display_name': unit_name, + } + + @staticmethod + def _get_last_visited_block_path_and_unit_name( + request: 'Request', # noqa: F821 + model: CourseEnrollment, + ) -> Tuple[List[Optional['XBlock']], Optional[str]]: # noqa: F821 + """ + Returns the path to the latest block and unit name visited by the current user. + + If there is no such visit, the first item deep enough down the course + tree is used. + """ + course = modulestore().get_course(model.course.id) + field_data_cache = FieldDataCache.cache_for_block_descendents( + course.id, model.user, course, depth=3) + + course_block = get_block_for_descriptor( + model.user, request, course, field_data_cache, course.id, course=course + ) + + unit_name = '' + path = [course_block] if course_block else [] + chapter = get_current_child(course_block, min_depth=3) + if chapter is not None: + path.append(chapter) + section = get_current_child(chapter, min_depth=2) + if section is not None: + path.append(section) + unit = get_current_child(section, min_depth=1) + if unit is not None: + unit_name = unit.display_name + + path.reverse() + return path, unit_name + + class Meta: + model = CourseEnrollment + fields = ( + 'audit_access_expires', + 'created', + 'mode', + 'is_active', + 'course', + 'certificate', + 'course_modes', + 'course_status', + ) + lookup_field = 'username' + + class UserSerializer(serializers.ModelSerializer): """ Serializes User models diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 08dc255426c2..8000576937bc 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 patch +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs import ddt @@ -492,8 +492,8 @@ def test_student_have_more_then_ten_enrollments(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 15) - self.assertEqual(response.data['enrollments']['num_pages'], 2) - self.assertEqual(len(response.data['enrollments']['results']), 10) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(len(response.data['enrollments']['results']), 5) self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) @@ -514,7 +514,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 6) - self.assertEqual(len(response.data['enrollments']['results']), 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that we have the new_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) @@ -529,7 +529,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 6) - self.assertEqual(len(response.data['enrollments']['results']), 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that now we have the old_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(old_course.id)) @@ -542,7 +542,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 7) - self.assertEqual(len(response.data['enrollments']['results']), 7) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that now we have the newest_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(newest_course.id)) @@ -613,6 +613,88 @@ def test_do_progress_in_not_mobile_available_course(self): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + def test_pagination_for_user_enrollments_api_v4(self): + """ + Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="my_org", mobile_available=True) for _ in range(15)] + 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) + self.assertEqual(response.data['enrollments']['count'], 14) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(response.data['enrollments']['current_page'], 1) + self.assertEqual(len(response.data['enrollments']['results']), 5) + self.assertIn('next', response.data['enrollments']) + self.assertIn('previous', response.data['enrollments']) + self.assertIn('primary', response.data) + + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], 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, + ): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + # create test course structure + course = CourseFactory.create(org="edx", mobile_available=True) + section = BlockFactory.create( + parent=course, + category="chapter", + display_name="section", + ) + subsection = BlockFactory.create( + parent=section, + category="sequential", + display_name="subsection", + ) + vertical = BlockFactory.create( + parent=subsection, + category="vertical", + display_name="test unit", + ) + problem = BlockFactory.create( + parent=vertical, + category="problem", + display_name="problem", + ) + self.enroll(course.id) + get_last_completed_block_mock.return_value = problem.location + expected_course_status = { + 'last_visited_module_id': str(subsection.location), + 'last_visited_module_path': [ + str(subsection.location), + str(section.location), + str(course.location) + ], + 'last_visited_block_id': str(problem.location), + 'last_visited_unit_display_name': vertical.display_name, + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], expected_course_status) + get_last_completed_block_mock.assert_called_once_with(self.user, course.id) + @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 d6d77dc1edf6..acf8b7179590 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -40,7 +40,12 @@ from .. import errors from ..decorators import mobile_course_access, mobile_view -from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05, UserSerializer +from .serializers import ( + CourseEnrollmentSerializer, + CourseEnrollmentSerializerModifiedForPrimary, + CourseEnrollmentSerializerv05, + UserSerializer, +) log = logging.getLogger(__name__) @@ -400,7 +405,10 @@ def list(self, request, *args, **kwargs): if api_version == API_V4: primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() if primary_enrollment_obj: - serializer = self.get_serializer(primary_enrollment_obj) + serializer = CourseEnrollmentSerializerModifiedForPrimary( + primary_enrollment_obj, + context=self.get_serializer_context(), + ) enrollment_data.update({'primary': serializer.data}) return Response(enrollment_data) @@ -456,8 +464,10 @@ def paginator(self): super().paginator # pylint: disable=expression-not-assigned api_version = self.kwargs.get('api_version') - if self._paginator is None and api_version in (API_V3, API_V4): + if self._paginator is None and api_version == API_V3: self._paginator = DefaultPagination() + if self._paginator is None and api_version == API_V4: + self._paginator = UserCourseEnrollmentsV4Pagination() return self._paginator @@ -472,3 +482,11 @@ def my_user_info(request, api_version): # updating it from the oauth2 related code is too complex user_logged_in.send(sender=User, user=request.user, request=request) return redirect("user-detail", api_version=api_version, username=request.user.username) + + +class UserCourseEnrollmentsV4Pagination(DefaultPagination): + """ + Pagination for `UserCourseEnrollments` API v4. + """ + page_size = 5 + max_page_size = 50