From a2347fd95d6ce2aa472d514b555be44cf10b3350 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Wed, 10 Jul 2024 18:07:41 +0300 Subject: [PATCH] feat: [FC-0047] Extend mobile API with course progress and primary courses on dashboard view (#34848) * feat: [AXM-24] Update structure for course enrollments API (#2515) --------- Co-authored-by: Glib Glugovskiy * feat: [AXM-53] add assertions for primary course (#2522) --------- Co-authored-by: monteri <36768631+monteri@users.noreply.github.com> * feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (#2546) --------- Co-authored-by: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Co-authored-by: Glib Glugovskiy Co-authored-by: monteri <36768631+monteri@users.noreply.github.com> Conflicts: lms/djangoapps/courseware/courses.py lms/djangoapps/mobile_api/users/tests.py --- .../student/models/course_enrollment.py | 62 ++ .../course_api/blocks/tests/test_views.py | 8 +- lms/djangoapps/courseware/courses.py | 109 +++- .../mobile_api/course_info/constants.py | 5 + .../mobile_api/course_info/serializers.py | 11 +- .../mobile_api/course_info/views.py | 51 +- .../tests/test_course_info_serializers.py | 46 +- .../tests/test_course_info_views.py | 28 + lms/djangoapps/mobile_api/users/enums.py | 22 + .../mobile_api/users/serializers.py | 93 ++- lms/djangoapps/mobile_api/users/tests.py | 617 +++++++++++++++++- lms/djangoapps/mobile_api/users/views.py | 164 ++++- lms/djangoapps/mobile_api/utils.py | 1 + lms/urls.py | 2 +- .../features/course_duration_limits/access.py | 4 +- 15 files changed, 1184 insertions(+), 39 deletions(-) create mode 100644 lms/djangoapps/mobile_api/course_info/constants.py create mode 100644 lms/djangoapps/mobile_api/users/enums.py diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index f879762679f4..106ddb4e3849 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -129,11 +129,73 @@ class UnenrollmentNotAllowed(CourseEnrollmentException): pass +class CourseEnrollmentQuerySet(models.QuerySet): + """ + Custom queryset for CourseEnrollment with Table-level filter methods. + """ + + def active(self): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently active. + """ + return self.filter(is_active=True) + + def without_certificates(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that do not have a certificate. + """ + return self.exclude(course_id__in=self.get_user_course_ids_with_certificates(username)) + + def with_certificates(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that have a certificate. + """ + return self.filter(course_id__in=self.get_user_course_ids_with_certificates(username)) + + def in_progress(self, username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently in progress. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(username).filter( + Q(course__start__lte=now, course__end__gte=now) + | Q(course__start__isnull=True, course__end__isnull=True) + | Q(course__start__isnull=True, course__end__gte=now) + | Q(course__start__lte=now, course__end__isnull=True), + ) + + def completed(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that have been completed. + """ + return self.active().with_certificates(username) + + def expired(self, username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that have expired. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(username).filter(course__end__lt=now) + + def get_user_course_ids_with_certificates(self, username): + """ + Gets user's course ids with certificates. + """ + from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel + course_ids_with_certificates = GeneratedCertificate.objects.filter( + user__username=username + ).values_list('course_id', flat=True) + return course_ids_with_certificates + + class CourseEnrollmentManager(models.Manager): """ Custom manager for CourseEnrollment with Table-level filter methods. """ + def get_queryset(self): + return CourseEnrollmentQuerySet(self.model, using=self._db) + def is_small_course(self, course_id): """ Returns false if the number of enrollments are one greater than 'max_enrollments' else true diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index e2426708d6bc..9898e72c8f25 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -5,7 +5,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 from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing @@ -207,8 +207,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.courseware.courses.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. """ @@ -366,7 +367,8 @@ def test_extra_field_when_not_requested(self): block_data['type'] == 'course' ) - def test_data_researcher_access(self): + @mock.patch('lms.djangoapps.courseware.courses.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 6ded860329fb..d0a73821098f 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -12,6 +12,7 @@ from crum import get_current_request from dateutil.parser import parse as parse_date from django.conf import settings +from django.core.cache import cache from django.http import Http404, QueryDict from django.urls import reverse from django.utils.translation import gettext as _ @@ -35,6 +36,7 @@ ) from lms.djangoapps.courseware.access_utils import check_authentication, check_data_sharing_consent, check_enrollment, \ check_correct_active_enterprise_customer, is_priority_access_error +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException from lms.djangoapps.courseware.date_summary import ( CertificateAvailableDate, @@ -50,7 +52,9 @@ from lms.djangoapps.courseware.masquerade import check_content_start_date_for_masquerade_user from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block +from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -587,7 +591,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,7 +611,8 @@ 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 due and graded: + + if (due or include_without_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( subsection_key, 'contains_gated_content', False) @@ -624,7 +629,11 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne else: complete = False - past_due = not complete and due < now + if due: + past_due = not complete and due < now + else: + past_due = False + due = None assignments.append(_Assignment( subsection_key, title, url, due, contains_gated_content, complete, past_due, assignment_type, None, first_component_block_id @@ -701,6 +710,39 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne return assignments +def get_assignments_grades(user, course_id, cache_timeout): + """ + Calculate the progress of the assignment for the user in the course. + + Arguments: + user (User): Django User object. + course_id (CourseLocator): The course key. + cache_timeout (int): Cache timeout in seconds + Returns: + list (ReadSubsectionGrade, ZeroSubsectionGrade): The list with assignments grades. + """ + is_staff = bool(has_access(user, 'staff', course_id)) + + try: + course = get_course_with_access(user, 'load', course_id) + cache_key = f'course_block_structure_{str(course_id)}_{str(course.course_version)}_{user.id}' + collected_block_structure = cache.get(cache_key) + if not collected_block_structure: + collected_block_structure = get_block_structure_manager(course_id).get_collected() + cache.set(cache_key, collected_block_structure, cache_timeout) + + course_grade = CourseGradeFactory().read(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()) + except Exception as err: # pylint: disable=broad-except + log.warning(f'Could not get grades for the course: {course_id}, error: {err}') + return [] + + return subsection_grades + + def get_first_component_of_block(block_key, block_data): """ This function returns the first leaf block of a section(block_key) @@ -956,3 +998,64 @@ def get_course_chapter_ids(course_key): log.exception('Failed to retrieve course from modulestore.') return [] return [str(chapter_key) for chapter_key in chapter_keys if chapter_key.block_type == 'chapter'] + + +def get_past_and_future_course_assignments(request, user, course): + """ + Returns the future assignment data and past assignments data for given user and course. + + Arguments: + request (Request): The HTTP GET request. + user (User): The user for whom the assignments are received. + course (Course): Course object for whom the assignments are received. + Returns: + tuple (list, list): Tuple of `past_assignments` list and `next_assignments` list. + `next_assignments` list contains only uncompleted assignments. + """ + assignments = get_course_assignment_date_blocks(course, user, request, include_past_dates=True) + past_assignments = [] + future_assignments = [] + + timezone = get_user_timezone_or_last_seen_timezone_or_utc(user) + for assignment in sorted(assignments, key=lambda x: x.date): + if assignment.date < datetime.now(timezone): + past_assignments.append(assignment) + else: + if not assignment.complete: + future_assignments.append(assignment) + + if future_assignments: + future_assignment_date = future_assignments[0].date.date() + next_assignments = [ + assignment for assignment in future_assignments if assignment.date.date() == future_assignment_date + ] + else: + next_assignments = [] + + return next_assignments, past_assignments + + +def get_assignments_completions(course_key, user): + """ + Calculate the progress of the user in the course by assignments. + + Arguments: + course_key (CourseLocator): The Course for which course progress is requested. + user (User): The user for whom course progress is requested. + Returns: + dict (dict): Dictionary contains information about total assignments count + in the given course and how many assignments the user has completed. + """ + course_assignments = get_course_assignments(course_key, 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, + } diff --git a/lms/djangoapps/mobile_api/course_info/constants.py b/lms/djangoapps/mobile_api/course_info/constants.py new file mode 100644 index 000000000000..d62cb463951a --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/constants.py @@ -0,0 +1,5 @@ +""" +Common constants for the `course_info` API. +""" + +BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index d7a9471088aa..69bed47b03fc 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_assignments_completions 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,12 @@ def get_course_modes(self, course_overview): for mode in course_modes ] + def get_course_progress(self, obj: CourseOverview) -> Dict[str, int]: + """ + Gets course progress calculated by course completed assignments. + """ + return get_assignments_completions(obj.id, self.context.get('user')) + 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 bd34336cc824..0279c59cf55c 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -3,7 +3,7 @@ """ import logging -from typing import Optional, Union +from typing import Dict, Optional, Union import django from django.contrib.auth import get_user_model @@ -17,9 +17,10 @@ from common.djangoapps.student.models import CourseEnrollment, User as StudentUser from common.djangoapps.static_replace import make_static_urls_absolute from lms.djangoapps.certificates.api import certificate_downloadable_status -from lms.djangoapps.courseware.courses import get_course_info_section_block +from lms.djangoapps.courseware.courses import get_assignments_grades, get_course_info_section_block from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.course_api.blocks.views import BlocksInCourseView +from lms.djangoapps.mobile_api.course_info.constants import BLOCK_STRUCTURE_CACHE_TIMEOUT from lms.djangoapps.mobile_api.course_info.serializers import ( CourseInfoOverviewSerializer, CourseAccessSerializer, @@ -269,6 +270,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** @@ -357,8 +363,14 @@ def list(self, request, **kwargs): # pylint: disable=W0221 course_info_context = {} if requested_user := self.get_requested_user(request.user, requested_username): + self._extend_sequential_info_with_assignment_progress( + requested_user, + course_key, + response.data['blocks'], + ) + course_info_context = { - 'user': requested_user + 'user': requested_user, } user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key) course_data.update({ @@ -380,3 +392,36 @@ def list(self, request, **kwargs): # pylint: disable=W0221 response.data.update(course_data) return response + + @staticmethod + def _extend_sequential_info_with_assignment_progress( + requested_user: User, + course_id: CourseKey, + blocks_info_data: Dict[str, Dict], + ) -> None: + """ + Extends sequential xblock info with assignment's name and progress. + """ + subsection_grades = get_assignments_grades(requested_user, course_id, BLOCK_STRUCTURE_CACHE_TIMEOUT) + grades_with_locations = {str(grade.location): grade for grade in subsection_grades} + + for block_id, block_info in blocks_info_data.items(): + if block_info['type'] == 'sequential': + grade = grades_with_locations.get(block_id) + if grade: + graded_total = grade.graded_total if grade.graded else None + points_earned = graded_total.earned if graded_total else 0 + points_possible = graded_total.possible if graded_total else 0 + assignment_type = grade.format + else: + points_earned, points_possible, assignment_type = 0, 0, None + + block_info.update( + { + 'assignment_progress': { + 'assignment_type': assignment_type, + 'num_points_earned': points_earned, + 'num_points_possible': points_possible, + } + } + ) 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 6c50f68d6811..d94fe3c11262 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_assignments_completions') + def test_get_media(self, get_assignments_completions_mock: MagicMock) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertIn('media', output_data) @@ -156,16 +157,53 @@ 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_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: + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_assignments_completions') + @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, + get_assignments_completions_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_assignments_completions') + def test_get_course_modes(self, get_assignments_completions_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.courseware.courses.get_course_assignments') + 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.courseware.courses.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/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py index 67d2c79f9017..a5175626a29e 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -422,3 +422,31 @@ def test_course_modes(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data['course_modes'], expected_course_modes) + + def test_extend_sequential_info_with_assignment_progress_get_only_sequential(self) -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': 'sequential'}) + + expected_results = ( + { + 'assignment_type': 'Lecture Sequence', + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + { + 'assignment_type': None, + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for sequential_info, assignment_progress in zip(response.data['blocks'].values(), expected_results): + self.assertDictEqual(sequential_info['assignment_progress'], assignment_progress) + + @ddt.data('chapter', 'vertical', 'problem', 'video', 'html') + def test_extend_sequential_info_with_assignment_progress_for_other_types(self, block_type: 'str') -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': block_type}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertNotEqual('assignment_progress', block_info) diff --git a/lms/djangoapps/mobile_api/users/enums.py b/lms/djangoapps/mobile_api/users/enums.py new file mode 100644 index 000000000000..2a072b082fff --- /dev/null +++ b/lms/djangoapps/mobile_api/users/enums.py @@ -0,0 +1,22 @@ +""" +Enums for mobile_api users app. +""" +from enum import Enum + + +class EnrollmentStatuses(Enum): + """ + Enum for enrollment statuses. + """ + + ALL = 'all' + IN_PROGRESS = 'in_progress' + COMPLETED = 'completed' + EXPIRED = 'expired' + + @classmethod + def values(cls): + """ + Returns string representation of all enum values. + """ + return [e.value for e in cls] diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index d7005e5f68e7..d8de11e50ff8 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -2,7 +2,11 @@ Serializer for user API """ +from typing import Dict, List, Optional +from completion.exceptions import UnavailableCompletionData +from completion.utilities import get_key_to_last_completed_block +from opaque_keys.edx.keys import UsageKey from rest_framework import serializers from rest_framework.reverse import reverse @@ -11,7 +15,13 @@ 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.courses import get_assignments_completions, get_past_and_future_course_assignments +from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer +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, NoPathToItem +from xmodule.modulestore.search import path_to_location class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: disable=abstract-method @@ -97,7 +107,7 @@ def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null """ - return get_user_course_expiration_date(model.user, model.course) + return get_user_course_expiration_date(model.user, model.course, model) def get_certificate(self, model): """Returns the information about the user's certificate in the course.""" @@ -124,6 +134,17 @@ def get_course_modes(self, obj): for mode in course_modes ] + def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # lint-amnesty, pylint: disable=unused-variable, line-too-long + """ + Override the to_representation method to add the course_status field to the serialized data. + """ + data = super().to_representation(instance) + + if 'course_progress' in self.context.get('requested_fields', []) and self.context.get('api_version') == API_V4: + data['course_progress'] = get_assignments_completions(instance.course_id, instance.user) + + return data + class Meta: model = CourseEnrollment fields = ('audit_access_expires', 'created', 'mode', 'is_active', 'course', 'certificate', 'course_modes') @@ -141,6 +162,76 @@ 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() + course_progress = serializers.SerializerMethodField() + course_assignments = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.course = modulestore().get_course(self.instance.course.id) + + def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: + """ + Gets course status for the given user's enrollments. + """ + try: + block_key = get_key_to_last_completed_block(model.user, model.course.id) + path = path_to_location(modulestore(), block_key, self.context['request'], full_path=True) + except (ItemNotFoundError, NoPathToItem, UnavailableCompletionData): + return None + + path_ids = [str(block) for block in path] + unit = modulestore().get_item(UsageKey.from_string(path_ids[3]), depth=0) + + return { + 'last_visited_module_id': path_ids[2], + 'last_visited_module_path': path_ids[:3], + 'last_visited_block_id': path_ids[-1], + 'last_visited_unit_display_name': unit.display_name, + } + + def get_course_progress(self, model: CourseEnrollment) -> Dict[str, int]: + """ + Returns the progress of the user in the course. + """ + return get_assignments_completions(model.course_id, model.user) + + def get_course_assignments(self, model: CourseEnrollment) -> Dict[str, Optional[List[Dict[str, str]]]]: + """ + Returns the future assignment data and past assignments data for the user in the course. + """ + next_assignments, past_assignments = get_past_and_future_course_assignments( + self.context.get('request'), model.user, self.course + ) + return { + 'future_assignments': DateSummarySerializer(next_assignments, many=True).data, + 'past_assignments': DateSummarySerializer(past_assignments, many=True).data, + } + + class Meta: + model = CourseEnrollment + fields = ( + 'audit_access_expires', + 'created', + 'mode', + 'is_active', + 'course', + 'certificate', + 'course_modes', + 'course_status', + 'course_progress', + 'course_assignments', + ) + 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 94ae9b64a1f3..fe1d32f00675 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -5,7 +5,7 @@ import datetime import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, Mock, patch from urllib.parse import parse_qs import ddt @@ -19,6 +19,7 @@ from django.utils.timezone import now from milestones.tests.utils import MilestonesTestCaseMixin from opaque_keys.edx.keys import CourseKey +from rest_framework import status from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -28,6 +29,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.mobile_api.models import MobileConfig from lms.djangoapps.mobile_api.testutils import ( MobileAPITestCase, @@ -35,7 +37,8 @@ MobileAuthUserTestMixin, MobileCourseAccessTestMixin ) -from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3 +from lms.djangoapps.mobile_api.users.enums import EnrollmentStatuses +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.core.lib.courses import course_image_url from openedx.core.release import RELEASE_LINE from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration @@ -408,6 +411,616 @@ def test_pagination_enrollment(self): assert "next" in response.data["enrollments"] assert "previous" in response.data["enrollments"] + def test_student_dont_have_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + expected_result = { + 'configs': { + 'iap_configs': {} + }, + 'user_timezone': 'UTC', + 'enrollments': { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_student_have_one_enrollment(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + expected_enrollments = { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_enrollments, response.data['enrollments']) + self.assertIn('primary', response.data) + self.assertEqual(str(course.id), response.data['primary']['course']['id']) + + def test_student_have_two_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course_first = CourseFactory.create(org="edx", mobile_available=True) + course_second = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course_first.id) + self.enroll(course_second.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['enrollments']['results']), 1) + self.assertEqual(response.data['enrollments']['count'], 1) + self.assertEqual(response.data['enrollments']['results'][0]['course']['id'], str(course_first.id)) + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) + + def test_student_have_more_then_ten_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(15)] + for course in courses: + self.enroll(course.id) + latest_enrolment = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(latest_enrolment.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 15) + 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)) + + def test_student_have_progress_in_old_course_and_enroll_newest_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + old_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(old_course.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_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'], 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)) + + # doing progress in the old_course + StudentModule.objects.create( + student=self.user, + course_id=old_course.id, + module_state_key=old_course.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 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)) + + # enroll to the newest course + newest_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(newest_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'], 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)) + + def test_student_enrolled_only_not_mobile_available_courses(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)] + for course in courses: + self.enroll(course.id) + expected_result = { + "configs": { + "iap_configs": {} + }, + "user_timezone": "UTC", + "enrollments": { + "next": None, + "previous": None, + "count": 0, + "num_pages": 1, + "current_page": 1, + "start": 0, + "results": [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_do_progress_in_not_mobile_available_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + not_mobile_available = CourseFactory.create(org="edx", mobile_available=False) + self.enroll(not_mobile_available.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_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'], 5) + 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)) + + # doing progress in the not_mobile_available course + StudentModule.objects.create( + student=self.user, + course_id=not_mobile_available.id, + module_state_key=not_mobile_available.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 5) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section in the same way + 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(course.location), + str(section.location), + str(subsection.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) + + def test_user_enrollment_api_v4_in_progress_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.IN_PROGRESS.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 2) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_completed_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.EXPIRED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_course_with_certificate(self): + """ + Testing that the API returns a course with + an expiration date in the past if the user has a certificate for this course. + """ + self.login() + expired_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + expired_course_with_cert = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=expired_course_with_cert.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(expired_course_with_cert.id) + self.enroll(expired_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(expired_course_with_cert.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_status_all(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.ALL.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 3) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + + def test_response_contains_primary_enrollment_assignments_info(self): + 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.assertIn('course_assignments', response.data['primary']) + self.assertIn('past_assignments', response.data['primary']['course_assignments']) + self.assertIn('future_assignments', response.data['primary']['course_assignments']) + self.assertListEqual(response.data['primary']['course_assignments']['past_assignments'], []) + self.assertListEqual(response.data['primary']['course_assignments']['future_assignments'], []) + + @patch('lms.djangoapps.courseware.courses.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.courseware.courses.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.courseware.courses.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.courseware.courses.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.courseware.courses.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 049678dcd7ba..d959e188b4ee 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -4,13 +4,15 @@ import logging +from functools import cached_property +from typing import Optional from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.signals import user_logged_in from django.db import transaction -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils import dateparse from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError @@ -26,19 +28,27 @@ from common.djangoapps.student.models import CourseEnrollment, User # lint-amnesty, pylint: disable=reimported from lms.djangoapps.courseware.access import is_mobile_available_for_user from lms.djangoapps.courseware.access_utils import ACCESS_GRANTED +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc from lms.djangoapps.courseware.courses import get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.views.index import save_positions_recursively_up from lms.djangoapps.mobile_api.models import MobileConfig -from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3 +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.features.course_duration_limits.access import check_course_expired from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from .. import errors from ..decorators import mobile_course_access, mobile_view -from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05, UserSerializer +from .enums import EnrollmentStatuses +from .serializers import ( + CourseEnrollmentSerializer, + CourseEnrollmentSerializerModifiedForPrimary, + CourseEnrollmentSerializerv05, + UserSerializer, +) log = logging.getLogger(__name__) @@ -263,6 +273,10 @@ class UserCourseEnrollmentsList(generics.ListAPIView): An additional attribute "expiration" has been added to the response, which lists the date when access to the course will expire or null if it doesn't expire. + In v4 we added to the response primary object. Primary object contains the latest user's enrollment + or course where user has the latest progress. Primary object has been cut from user's + enrolments array and inserted into separated section with key `primary`. + **Example Request** GET /api/mobile/v1/users/{username}/course_enrollments/ @@ -312,8 +326,12 @@ 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. """ - queryset = CourseEnrollment.objects.all() + lookup_field = 'username' # In Django Rest Framework v3, there is a default pagination @@ -332,7 +350,10 @@ def is_org(self, check_org, course_org): def get_serializer_context(self): context = super().get_serializer_context() + requested_fields = self.request.GET.get('requested_fields', '') + context['api_version'] = self.kwargs.get('api_version') + context['requested_fields'] = requested_fields.split(',') return context def get_serializer_class(self): @@ -341,47 +362,142 @@ def get_serializer_class(self): return CourseEnrollmentSerializerv05 return CourseEnrollmentSerializer - def get_queryset(self): + @cached_property + def queryset_for_user(self): + """ + Find and return the list of course enrollments for the user. + + In v4 added filtering by statuses. + """ api_version = self.kwargs.get('api_version') - enrollments = self.queryset.filter( - user__username=self.kwargs['username'], + status = self.request.GET.get('status') + username = self.kwargs['username'] + + queryset = CourseEnrollment.objects.all().select_related('course', 'user').filter( + user__username=username, is_active=True - ).order_by('created').reverse() - org = self.request.query_params.get('org', None) + ).order_by('-created') + + if api_version == API_V4 and status in EnrollmentStatuses.values(): + if status == EnrollmentStatuses.IN_PROGRESS.value: + queryset = queryset.in_progress(username=username, time_zone=self.user_timezone) + elif status == EnrollmentStatuses.COMPLETED.value: + queryset = queryset.completed(username=username) + elif status == EnrollmentStatuses.EXPIRED.value: + queryset = queryset.expired(username=username, time_zone=self.user_timezone) + + return queryset + + def get_queryset(self): + api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') + mobile_available = self.get_same_org_mobile_available_enrollments() - same_org = ( - enrollment for enrollment in enrollments - if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org) - ) - mobile_available = ( - enrollment for enrollment in same_org - if is_mobile_available_for_user(self.request.user, enrollment.course_overview) - ) not_duration_limited = ( enrollment for enrollment in mobile_available if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED ) + if api_version == API_V4 and status not in EnrollmentStatuses.values(): + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + mobile_available.remove(primary_enrollment_obj) + if api_version == API_V05: # for v0.5 don't return expired courses return list(not_duration_limited) else: # return all courses, with associated expiration - return list(mobile_available) + return mobile_available + + def get_same_org_mobile_available_enrollments(self) -> list[CourseEnrollment]: + """ + Gets list with `CourseEnrollment` for mobile available courses. + """ + org = self.request.query_params.get('org', None) + + same_org = ( + enrollment for enrollment in self.queryset_for_user + if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org) + ) + mobile_available = ( + enrollment for enrollment in same_org + if is_mobile_available_for_user(self.request.user, enrollment.course_overview) + ) + return list(mobile_available) def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') - if api_version in (API_V2, API_V3): + if api_version in (API_V2, API_V3, API_V4): enrollment_data = { 'configs': MobileConfig.get_structured_configs(), + 'user_timezone': str(self.user_timezone), 'enrollments': response.data } + if api_version == API_V4 and status not in EnrollmentStatuses.values(): + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + serializer = CourseEnrollmentSerializerModifiedForPrimary( + primary_enrollment_obj, + context=self.get_serializer_context(), + ) + enrollment_data.update({'primary': serializer.data}) + return Response(enrollment_data) return response + @cached_property + def user_timezone(self): + """ + Get the user's timezone. + """ + return get_user_timezone_or_last_seen_timezone_or_utc(self.get_user()) + + def get_user(self) -> User: + """ + Get user object by username. + """ + return get_object_or_404(User, username=self.kwargs['username']) + + def get_primary_enrollment_by_latest_enrollment_or_progress(self) -> Optional[CourseEnrollment]: + """ + Gets primary enrollment obj by latest enrollment or latest progress on the course. + """ + mobile_available = self.get_same_org_mobile_available_enrollments() + if not mobile_available: + return None + + mobile_available_course_ids = [enrollment.course_id for enrollment in mobile_available] + + latest_enrollment = self.queryset_for_user.filter( + course__id__in=mobile_available_course_ids + ).order_by('-created').first() + + if not latest_enrollment: + return None + + latest_progress = StudentModule.objects.filter( + student__username=self.kwargs['username'], + course_id__in=mobile_available_course_ids, + ).order_by('-modified').first() + + if not latest_progress: + return latest_enrollment + + enrollment_with_latest_progress = self.queryset_for_user.filter( + course_id=latest_progress.course_id, + user__username=self.kwargs['username'], + ).first() + + if latest_enrollment.created > latest_progress.modified: + return latest_enrollment + else: + return enrollment_with_latest_progress + # pylint: disable=attribute-defined-outside-init @property def paginator(self): @@ -396,6 +512,8 @@ def paginator(self): 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 @@ -410,3 +528,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 diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 73a0cfea0827..9204b27ab49b 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -6,6 +6,7 @@ API_V1 = 'v1' API_V2 = 'v2' API_V3 = 'v3' +API_V4 = 'v4' def parsed_version(version): diff --git a/lms/urls.py b/lms/urls.py index b03c084d0215..78fa5c52b38c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -222,7 +222,7 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): urlpatterns += [ - re_path(r'^api/mobile/(?Pv(3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), + re_path(r'^api/mobile/(?Pv(4|3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] if settings.FEATURES.get('ENABLE_OPENBADGES'): diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py index ff817a315054..8f73ae2266ea 100644 --- a/openedx/features/course_duration_limits/access.py +++ b/openedx/features/course_duration_limits/access.py @@ -68,7 +68,7 @@ def get_user_course_duration(user, course): return get_expected_duration(course.id) -def get_user_course_expiration_date(user, course): +def get_user_course_expiration_date(user, course, enrollment=None): """ Return expiration date for given user course pair. Return None if the course does not expire. @@ -81,7 +81,7 @@ def get_user_course_expiration_date(user, course): if access_duration is None: return None - enrollment = CourseEnrollment.get_enrollment(user, course.id) + enrollment = enrollment or CourseEnrollment.get_enrollment(user, course.id) if enrollment is None or enrollment.mode != CourseMode.AUDIT: return None