From 40bdaf923cc89867b7c6b5ce131691351aa4c0a4 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 --- .../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 = (