Skip to content

Commit cdd2d7e

Browse files
committed
feat: [AXM-287,310,331] Change course progress calculation logic (#2553)
* feat: [AXM-287,310,331] Change course progress calculation logic * style: [AXM-287,310,331] Remove commented code * fix: [AXM-287,310,331] Change course assignments gather logic
1 parent 24f63cc commit cdd2d7e

File tree

8 files changed

+226
-58
lines changed

8 files changed

+226
-58
lines changed

lms/djangoapps/course_api/blocks/tests/test_views.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from datetime import datetime
55
from unittest import mock
6-
from unittest.mock import Mock
6+
from unittest.mock import MagicMock, Mock
77
from urllib.parse import urlencode, urlunparse
88

99
import ddt
@@ -209,8 +209,9 @@ def test_not_authenticated_public_course_with_all_blocks(self):
209209
self.query_params['all_blocks'] = True
210210
self.verify_response(403)
211211

212+
@mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
212213
@mock.patch("lms.djangoapps.course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True))
213-
def test_not_authenticated_public_course_with_blank_username(self):
214+
def test_not_authenticated_public_course_with_blank_username(self, get_course_assignment_mock: MagicMock) -> None:
214215
"""
215216
Verify behaviour when accessing course blocks of a public course for anonymous user anonymously.
216217
"""
@@ -368,7 +369,8 @@ def test_extra_field_when_not_requested(self):
368369
block_data['type'] == 'course'
369370
)
370371

371-
def test_data_researcher_access(self):
372+
@mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
373+
def test_data_researcher_access(self, get_course_assignment_mock: MagicMock) -> None:
372374
"""
373375
Test if data researcher has access to the api endpoint
374376
"""

lms/djangoapps/courseware/courses.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import logging
77
from collections import defaultdict, namedtuple
8-
from datetime import datetime
8+
from datetime import datetime, timedelta
99

1010
import six
1111
import pytz
@@ -587,7 +587,7 @@ def get_course_blocks_completion_summary(course_key, user):
587587

588588

589589
@request_cached()
590-
def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements
590+
def get_course_assignments(course_key, user, include_access=False, include_without_due=False,): # lint-amnesty, pylint: disable=too-many-statements
591591
"""
592592
Returns a list of assignment (at the subsection/sequential level) due dates for the given course.
593593
@@ -607,6 +607,10 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne
607607
for subsection_key in block_data.get_children(section_key):
608608
due = block_data.get_xblock_field(subsection_key, 'due')
609609
graded = block_data.get_xblock_field(subsection_key, 'graded', False)
610+
611+
if not due and include_without_due:
612+
due = now + timedelta(days=1000)
613+
610614
if due and graded:
611615
first_component_block_id = get_first_component_of_block(subsection_key, block_data)
612616
contains_gated_content = include_access and block_data.get_xblock_field(

lms/djangoapps/mobile_api/course_info/serializers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Course Info serializers
33
"""
44
from rest_framework import serializers
5-
from typing import Union
5+
from typing import Dict, Union
66

77
from common.djangoapps.course_modes.models import CourseMode
88
from common.djangoapps.student.models import CourseEnrollment
@@ -13,6 +13,7 @@
1313
from lms.djangoapps.courseware.access import has_access
1414
from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user
1515
from lms.djangoapps.courseware.access_utils import check_course_open_for_learner
16+
from lms.djangoapps.courseware.courses import get_course_assignments
1617
from lms.djangoapps.mobile_api.users.serializers import ModeSerializer
1718
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
1819
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
@@ -31,6 +32,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer):
3132
course_sharing_utm_parameters = serializers.SerializerMethodField()
3233
course_about = serializers.SerializerMethodField('get_course_about_url')
3334
course_modes = serializers.SerializerMethodField()
35+
course_progress = serializers.SerializerMethodField()
3436

3537
class Meta:
3638
model = CourseOverview
@@ -47,6 +49,7 @@ class Meta:
4749
'course_sharing_utm_parameters',
4850
'course_about',
4951
'course_modes',
52+
'course_progress',
5053
)
5154

5255
@staticmethod
@@ -75,6 +78,28 @@ def get_course_modes(self, course_overview):
7578
for mode in course_modes
7679
]
7780

81+
def get_course_progress(self, obj: 'CourseOverview') -> Dict[str, int]: # noqa: F821 #here
82+
"""
83+
Gets course progress calculated by course assignments.
84+
"""
85+
course_assignments = get_course_assignments(
86+
obj.id,
87+
self.context.get('user'),
88+
include_without_due=True,
89+
)
90+
91+
total_assignments_count = 0
92+
assignments_completed = 0
93+
94+
if course_assignments:
95+
total_assignments_count = len(course_assignments)
96+
assignments_completed = len([assignment for assignment in course_assignments if assignment.complete])
97+
98+
return {
99+
'total_assignments_count': total_assignments_count,
100+
'assignments_completed': assignments_completed,
101+
}
102+
78103

79104
class MobileCourseEnrollmentSerializer(serializers.ModelSerializer):
80105
"""

lms/djangoapps/mobile_api/course_info/views.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,11 @@ class BlocksInfoInCourseView(BlocksInCourseView):
271271
course, chapter, sequential, vertical, html, problem, video, and
272272
discussion.
273273
display_name: (str) The display name of the block.
274+
course_progress: (dict) Contains information about how many assignments are in the course
275+
and how many assignments the student has completed.
276+
Included here:
277+
* total_assignments_count: (int) Total course's assignments count.
278+
* assignments_completed: (int) Assignments witch the student has completed.
274279
275280
**Returns**
276281
@@ -366,7 +371,7 @@ def list(self, request, **kwargs): # pylint: disable=W0221
366371
)
367372

368373
course_info_context = {
369-
'user': requested_user
374+
'user': requested_user,
370375
}
371376
user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key)
372377
course_data.update({

lms/djangoapps/mobile_api/tests/test_course_info_serializers.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ def setUp(self):
147147
self.user = UserFactory()
148148
self.course_overview = CourseOverviewFactory()
149149

150-
def test_get_media(self):
150+
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
151+
def test_get_media(self, get_course_assignment_mock: MagicMock) -> None:
151152
output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data
152153

153154
self.assertIn('media', output_data)
@@ -156,16 +157,46 @@ def test_get_media(self):
156157
self.assertIn('small', output_data['media']['image'])
157158
self.assertIn('large', output_data['media']['image'])
158159

160+
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
159161
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_link_for_about_page', return_value='mock_about_link')
160-
def test_get_course_sharing_utm_parameters(self, mock_get_link_for_about_page: MagicMock) -> None:
162+
def test_get_course_sharing_utm_parameters(
163+
self,
164+
mock_get_link_for_about_page: MagicMock,
165+
get_course_assignment_mock: MagicMock,
166+
) -> None:
161167
output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data
162168

163169
self.assertEqual(output_data['course_about'], mock_get_link_for_about_page.return_value)
164170
mock_get_link_for_about_page.assert_called_once_with(self.course_overview)
165171

166-
def test_get_course_modes(self):
172+
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
173+
def test_get_course_modes(self, get_course_assignment_mock: MagicMock) -> None:
167174
expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}]
168175

169176
output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data
170177

171178
self.assertListEqual(output_data['course_modes'], expected_course_modes)
179+
180+
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[])
181+
def test_get_course_progress_no_assignments(self, get_course_assignment_mock: MagicMock) -> None:
182+
expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0}
183+
184+
output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data
185+
186+
self.assertIn('course_progress', output_data)
187+
self.assertDictEqual(output_data['course_progress'], expected_course_progress)
188+
get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True)
189+
190+
@patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments')
191+
def test_get_course_progress_with_assignments(self, get_course_assignment_mock: MagicMock) -> None:
192+
assignments_mock = [
193+
Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True)
194+
]
195+
get_course_assignment_mock.return_value = assignments_mock
196+
expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3}
197+
198+
output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data
199+
200+
self.assertIn('course_progress', output_data)
201+
self.assertDictEqual(output_data['course_progress'], expected_course_progress)
202+
get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True)

lms/djangoapps/mobile_api/users/serializers.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from datetime import datetime
66
from typing import Dict, List, Optional, Tuple, Union
77

8-
from django.core.cache import cache
98
from completion.exceptions import UnavailableCompletionData
109
from completion.utilities import get_key_to_last_completed_block
1110
from opaque_keys import InvalidKeyError
@@ -19,10 +18,9 @@
1918
from lms.djangoapps.certificates.api import certificate_downloadable_status
2019
from lms.djangoapps.courseware.access import has_access
2120
from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc
22-
from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks
21+
from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks, get_course_assignments
2322
from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer
24-
from lms.djangoapps.grades.api import CourseGradeFactory
25-
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
23+
from lms.djangoapps.mobile_api.utils import API_V4
2624
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
2725
from xmodule.modulestore.django import modulestore
2826
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -107,8 +105,6 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer):
107105
audit_access_expires = serializers.SerializerMethodField()
108106
course_modes = serializers.SerializerMethodField()
109107

110-
BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour
111-
112108
def get_audit_access_expires(self, model):
113109
"""
114110
Returns expiration date for a course audit expiration, if any or null
@@ -140,38 +136,37 @@ def get_course_modes(self, obj):
140136
for mode in course_modes
141137
]
142138

143-
def to_representation(self, instance):
139+
def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # noqa: F821
144140
"""
145141
Override the to_representation method to add the course_status field to the serialized data.
146142
"""
147143
data = super().to_representation(instance)
148-
if 'progress' in self.context.get('requested_fields', []):
149-
data['progress'] = self.calculate_progress(instance)
144+
145+
if 'course_progress' in self.context.get('requested_fields', []) and self.context.get('api_version') == API_V4:
146+
data['course_progress'] = self.calculate_progress(instance)
150147

151148
return data
152149

153150
def calculate_progress(self, model: CourseEnrollment) -> Dict[str, int]:
154151
"""
155152
Calculate the progress of the user in the course.
156-
:param model:
157-
:return:
158153
"""
159-
is_staff = bool(has_access(model.user, 'staff', model.course.id))
154+
course_assignments = get_course_assignments(
155+
model.course_id,
156+
model.user,
157+
include_without_due=True,
158+
)
160159

161-
cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}'
162-
collected_block_structure = cache.get(cache_key)
163-
if not collected_block_structure:
164-
collected_block_structure = get_block_structure_manager(model.course.id).get_collected()
165-
cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT)
160+
total_assignments_count = 0
161+
assignments_completed = 0
166162

167-
course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure)
163+
if course_assignments:
164+
total_assignments_count = len(course_assignments)
165+
assignments_completed = len([assignment for assignment in course_assignments if assignment.complete])
168166

169-
# recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not)
170-
course_grade.update(visible_grades_only=True, has_staff_access=is_staff)
171-
subsection_grades = list(course_grade.subsection_grades.values())
172167
return {
173-
'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)),
174-
'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)),
168+
'total_assignments_count': total_assignments_count,
169+
'assignments_completed': assignments_completed,
175170
}
176171

177172
class Meta:
@@ -199,7 +194,7 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer):
199194
"""
200195

201196
course_status = serializers.SerializerMethodField()
202-
progress = serializers.SerializerMethodField()
197+
course_progress = serializers.SerializerMethodField()
203198
course_assignments = serializers.SerializerMethodField()
204199

205200
def __init__(self, *args, **kwargs):
@@ -213,7 +208,7 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[
213208
try:
214209
block_id = str(get_key_to_last_completed_block(model.user, model.course.id))
215210
except UnavailableCompletionData:
216-
block_id = ""
211+
block_id = ''
217212

218213
if not block_id:
219214
return None
@@ -251,7 +246,7 @@ def _get_last_visited_block_path_and_unit_name(
251246

252247
return path, vertical.display_name
253248

254-
def get_progress(self, model: CourseEnrollment) -> Dict[str, int]:
249+
def get_course_progress(self, model: CourseEnrollment) -> Dict[str, int]:
255250
"""
256251
Returns the progress of the user in the course.
257252
"""
@@ -302,7 +297,7 @@ class Meta:
302297
'certificate',
303298
'course_modes',
304299
'course_status',
305-
'progress',
300+
'course_progress',
306301
'course_assignments',
307302
)
308303
lookup_field = 'username'

0 commit comments

Comments
 (0)