Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [AXM-47] Add course_status field to primary object #2517

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions lms/djangoapps/mobile_api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 88 additions & 6 deletions lms/djangoapps/mobile_api/users/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 21 additions & 3 deletions lms/djangoapps/mobile_api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Loading