From d97d68bca8aa4332eddde94f5af7e3708108c3fa 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