From 8644c2193087f6e0b3f2e3c2291fc0d9ffc78f24 Mon Sep 17 00:00:00 2001
From: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com>
Date: Fri, 22 Mar 2024 19:00:32 +0200
Subject: [PATCH] feat: [AXM-40] add courses progress to enrollment endpoint
 (#2519)

* fix: workaround for staticcollection introduced in e40a01c

* feat: [AXM-40] add courses progress to enrollment endpoint

* refactor: [AXM-40] add caching to improve performance

* refactor: [AXM-40] add progress only for primary course

* refactor: [AXM-40] refactor enrollment caching optimization

---------

Co-authored-by: Glib Glugovskiy <glib.glugovskiy@raccoongang.com>
---
 .../mobile_api/users/serializers.py           | 31 +++++++++++++++++++
 lms/djangoapps/mobile_api/users/views.py      | 16 ++++++----
 2 files changed, 41 insertions(+), 6 deletions(-)

diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py
index 944f3c36defd..64dffed1f045 100644
--- a/lms/djangoapps/mobile_api/users/serializers.py
+++ b/lms/djangoapps/mobile_api/users/serializers.py
@@ -4,6 +4,7 @@
 
 from typing import Dict, List, Optional, Tuple
 
+from django.core.cache import cache
 from completion.exceptions import UnavailableCompletionData
 from completion.utilities import get_key_to_last_completed_block
 from rest_framework import serializers
@@ -17,6 +18,8 @@
 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 lms.djangoapps.grades.api import CourseGradeFactory
+from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
 from openedx.features.course_duration_limits.access import get_user_course_expiration_date
 from xmodule.modulestore.django import modulestore
 
@@ -154,7 +157,11 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer):
 
     Adds `course_status` field into serializer data.
     """
+
     course_status = serializers.SerializerMethodField()
+    progress = serializers.SerializerMethodField()
+
+    BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60  # 1 hour
 
     def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]:
         """
@@ -213,6 +220,29 @@ def _get_last_visited_block_path_and_unit_name(
         path.reverse()
         return path, unit_name
 
+    def get_progress(self, model: CourseEnrollment) -> Dict[str, int]:
+        """
+        Returns the progress of the user in the course.
+        """
+        assert isinstance(model, CourseEnrollment), f'Expected CourseEnrollment, got {type(model)}'
+        is_staff = bool(has_access(model.user, 'staff', model.course.id))
+
+        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)
+
+        course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure)
+
+        # 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)),
+        }
+
     class Meta:
         model = CourseEnrollment
         fields = (
@@ -224,6 +254,7 @@ class Meta:
             'certificate',
             'course_modes',
             'course_status',
+            'progress',
         )
         lookup_field = 'username'
 
diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py
index acf8b7179590..2463ef963b9e 100644
--- a/lms/djangoapps/mobile_api/users/views.py
+++ b/lms/djangoapps/mobile_api/users/views.py
@@ -4,6 +4,7 @@
 
 
 import logging
+from functools import cached_property
 from typing import List, Optional
 
 from completion.exceptions import UnavailableCompletionData
@@ -324,7 +325,7 @@ class UserCourseEnrollmentsList(generics.ListAPIView):
           certified).
         * url: URL to the downloadable version of the certificate, if exists.
     """
-    queryset = CourseEnrollment.objects.all()
+
     lookup_field = 'username'
 
     # In Django Rest Framework v3, there is a default pagination
@@ -352,6 +353,13 @@ def get_serializer_class(self):
             return CourseEnrollmentSerializerv05
         return CourseEnrollmentSerializer
 
+    @cached_property
+    def queryset(self):
+        return CourseEnrollment.objects.all().select_related('course', 'user').filter(
+            user__username=self.kwargs['username'],
+            is_active=True
+        ).order_by('created').reverse()
+
     def get_queryset(self):
         api_version = self.kwargs.get('api_version')
         mobile_available = self.get_mobile_available_enrollments()
@@ -377,14 +385,10 @@ def get_mobile_available_enrollments(self) -> List[Optional[CourseEnrollment]]:
         """
         Gets list with `CourseEnrollment` for mobile available courses.
         """
-        enrollments = self.queryset.filter(
-            user__username=self.kwargs['username'],
-            is_active=True
-        ).order_by('created').reverse()
         org = self.request.query_params.get('org', None)
 
         same_org = (
-            enrollment for enrollment in enrollments
+            enrollment for enrollment in self.queryset
             if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org)
         )
         mobile_available = (