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:
Kyrylo Kireiev
2024-07-10 18:07:41 +03:00
committed by GitHub
parent 7124559906
commit 53174178f3
15 changed files with 1184 additions and 39 deletions

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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,
}

View File

@@ -0,0 +1,5 @@
"""
Common constants for the `course_info` API.
"""
BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour

View File

@@ -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):
"""

View File

@@ -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,
}
}
)

View File

@@ -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
)

View File

@@ -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)

View 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]

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -6,6 +6,7 @@ API_V05 = 'v0.5'
API_V1 = 'v1'
API_V2 = 'v2'
API_V3 = 'v3'
API_V4 = 'v4'
def parsed_version(version):

View File

@@ -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 += [

View File

@@ -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