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 <glib.glugovskiy@raccoongang.com> * 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 <glib.glugovskiy@raccoongang.com> Co-authored-by: monteri <36768631+monteri@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ Tests for Blocks Views
|
||||
"""
|
||||
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
|
||||
|
||||
import ddt
|
||||
@@ -209,8 +209,9 @@ class TestBlocksView(SharedModuleStoreTestCase):
|
||||
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.
|
||||
"""
|
||||
@@ -368,7 +369,8 @@ class TestBlocksView(SharedModuleStoreTestCase):
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytz
|
||||
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_response import (
|
||||
)
|
||||
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.exceptions import CourseAccessRedirect, CourseRun
|
||||
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
|
||||
@@ -764,6 +773,39 @@ def _ora_assessment_to_assignment(
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -1019,3 +1061,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,
|
||||
}
|
||||
|
||||
5
lms/djangoapps/mobile_api/course_info/constants.py
Normal file
5
lms/djangoapps/mobile_api/course_info/constants.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Common constants for the `course_info` API.
|
||||
"""
|
||||
|
||||
BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour
|
||||
@@ -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 common.djangoapps.util.milestones_helpers import (
|
||||
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 CourseInfoOverviewSerializer(serializers.ModelSerializer):
|
||||
'course_sharing_utm_parameters',
|
||||
'course_about',
|
||||
'course_modes',
|
||||
'course_progress',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -75,6 +78,12 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@ Views for course info API
|
||||
"""
|
||||
|
||||
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 rest_framework.views import APIView
|
||||
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 @@ class BlocksInfoInCourseView(BlocksInCourseView):
|
||||
|
||||
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 @@ class BlocksInfoInCourseView(BlocksInCourseView):
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -147,7 +147,8 @@ class TestCourseInfoOverviewSerializer(TestCase):
|
||||
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 @@ class TestCourseInfoOverviewSerializer(TestCase):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -422,3 +422,31 @@ class TestBlocksInfoInCourseView(TestBlocksInCourseView, MilestonesTestCaseMixin
|
||||
|
||||
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)
|
||||
|
||||
22
lms/djangoapps/mobile_api/users/enums.py
Normal file
22
lms/djangoapps/mobile_api/users/enums.py
Normal file
@@ -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]
|
||||
@@ -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.student.models import CourseEnrollment, User
|
||||
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 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
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 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||
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 CourseEnrollmentSerializerv05(CourseEnrollmentSerializer):
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ Tests for users API
|
||||
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import ddt
|
||||
@@ -18,6 +18,7 @@ from django.utils import timezone
|
||||
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
|
||||
@@ -27,6 +28,7 @@ from common.djangoapps.util.testing import UrlResetMixin
|
||||
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,
|
||||
@@ -34,7 +36,8 @@ from lms.djangoapps.mobile_api.testutils import (
|
||||
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.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
|
||||
@@ -406,6 +409,616 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
|
||||
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):
|
||||
|
||||
@@ -4,13 +4,15 @@ Views for user API
|
||||
|
||||
|
||||
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 edx_rest_framework_extensions.paginators import DefaultPagination
|
||||
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 @@ class UserCourseEnrollmentsList(generics.ListAPIView):
|
||||
|
||||
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 @@ class UserCourseEnrollmentsList(generics.ListAPIView):
|
||||
return CourseEnrollmentSerializerv05
|
||||
return CourseEnrollmentSerializer
|
||||
|
||||
@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')
|
||||
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')
|
||||
|
||||
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')
|
||||
enrollments = self.queryset.filter(
|
||||
user__username=self.kwargs['username'],
|
||||
is_active=True
|
||||
).order_by('created').reverse()
|
||||
org = self.request.query_params.get('org', None)
|
||||
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 @@ class UserCourseEnrollmentsList(generics.ListAPIView):
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ API_V05 = 'v0.5'
|
||||
API_V1 = 'v1'
|
||||
API_V2 = 'v2'
|
||||
API_V3 = 'v3'
|
||||
API_V4 = 'v4'
|
||||
|
||||
|
||||
def parsed_version(version):
|
||||
|
||||
@@ -221,7 +221,7 @@ urlpatterns = [
|
||||
|
||||
if settings.FEATURES.get('ENABLE_MOBILE_REST_API'):
|
||||
urlpatterns += [
|
||||
re_path(r'^api/mobile/(?P<api_version>v(3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')),
|
||||
re_path(r'^api/mobile/(?P<api_version>v(4|3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user