Skip to content

Commit a2347fd

Browse files
KyryloKireievOmarIthawi
authored andcommitted
feat: [FC-0047] Extend mobile API with course progress and primary courses on dashboard view (openedx#34848)
* feat: [AXM-24] Update structure for course enrollments API (openedx#2515) --------- Co-authored-by: Glib Glugovskiy <[email protected]> * feat: [AXM-53] add assertions for primary course (openedx#2522) --------- Co-authored-by: monteri <[email protected]> * feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (openedx#2546) --------- Co-authored-by: NiedielnitsevIvan <[email protected]> Co-authored-by: Glib Glugovskiy <[email protected]> Co-authored-by: monteri <[email protected]> Conflicts: lms/djangoapps/courseware/courses.py lms/djangoapps/mobile_api/users/tests.py
1 parent d653bf5 commit a2347fd

File tree

15 files changed

+1184
-39
lines changed

15 files changed

+1184
-39
lines changed

common/djangoapps/student/models/course_enrollment.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,73 @@ class UnenrollmentNotAllowed(CourseEnrollmentException):
129129
pass
130130

131131

132+
class CourseEnrollmentQuerySet(models.QuerySet):
133+
"""
134+
Custom queryset for CourseEnrollment with Table-level filter methods.
135+
"""
136+
137+
def active(self):
138+
"""
139+
Returns a queryset of CourseEnrollment objects for courses that are currently active.
140+
"""
141+
return self.filter(is_active=True)
142+
143+
def without_certificates(self, username):
144+
"""
145+
Returns a queryset of CourseEnrollment objects for courses that do not have a certificate.
146+
"""
147+
return self.exclude(course_id__in=self.get_user_course_ids_with_certificates(username))
148+
149+
def with_certificates(self, username):
150+
"""
151+
Returns a queryset of CourseEnrollment objects for courses that have a certificate.
152+
"""
153+
return self.filter(course_id__in=self.get_user_course_ids_with_certificates(username))
154+
155+
def in_progress(self, username, time_zone=UTC):
156+
"""
157+
Returns a queryset of CourseEnrollment objects for courses that are currently in progress.
158+
"""
159+
now = datetime.now(time_zone)
160+
return self.active().without_certificates(username).filter(
161+
Q(course__start__lte=now, course__end__gte=now)
162+
| Q(course__start__isnull=True, course__end__isnull=True)
163+
| Q(course__start__isnull=True, course__end__gte=now)
164+
| Q(course__start__lte=now, course__end__isnull=True),
165+
)
166+
167+
def completed(self, username):
168+
"""
169+
Returns a queryset of CourseEnrollment objects for courses that have been completed.
170+
"""
171+
return self.active().with_certificates(username)
172+
173+
def expired(self, username, time_zone=UTC):
174+
"""
175+
Returns a queryset of CourseEnrollment objects for courses that have expired.
176+
"""
177+
now = datetime.now(time_zone)
178+
return self.active().without_certificates(username).filter(course__end__lt=now)
179+
180+
def get_user_course_ids_with_certificates(self, username):
181+
"""
182+
Gets user's course ids with certificates.
183+
"""
184+
from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel
185+
course_ids_with_certificates = GeneratedCertificate.objects.filter(
186+
user__username=username
187+
).values_list('course_id', flat=True)
188+
return course_ids_with_certificates
189+
190+
132191
class CourseEnrollmentManager(models.Manager):
133192
"""
134193
Custom manager for CourseEnrollment with Table-level filter methods.
135194
"""
136195

196+
def get_queryset(self):
197+
return CourseEnrollmentQuerySet(self.model, using=self._db)
198+
137199
def is_small_course(self, course_id):
138200
"""
139201
Returns false if the number of enrollments are one greater than 'max_enrollments' else true

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

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

66
from datetime import datetime
77
from unittest import mock
8-
from unittest.mock import Mock
8+
from unittest.mock import MagicMock, Mock
99
from urllib.parse import urlencode, urlunparse
1010

1111
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
@@ -207,8 +207,9 @@ def test_not_authenticated_public_course_with_all_blocks(self):
207207
self.query_params['all_blocks'] = True
208208
self.verify_response(403)
209209

210+
@mock.patch('lms.djangoapps.courseware.courses.get_course_assignments', return_value=[])
210211
@mock.patch("lms.djangoapps.course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True))
211-
def test_not_authenticated_public_course_with_blank_username(self):
212+
def test_not_authenticated_public_course_with_blank_username(self, get_course_assignment_mock: MagicMock) -> None:
212213
"""
213214
Verify behaviour when accessing course blocks of a public course for anonymous user anonymously.
214215
"""
@@ -366,7 +367,8 @@ def test_extra_field_when_not_requested(self):
366367
block_data['type'] == 'course'
367368
)
368369

369-
def test_data_researcher_access(self):
370+
@mock.patch('lms.djangoapps.courseware.courses.get_course_assignments', return_value=[])
371+
def test_data_researcher_access(self, get_course_assignment_mock: MagicMock) -> None:
370372
"""
371373
Test if data researcher has access to the api endpoint
372374
"""

lms/djangoapps/courseware/courses.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from crum import get_current_request
1313
from dateutil.parser import parse as parse_date
1414
from django.conf import settings
15+
from django.core.cache import cache
1516
from django.http import Http404, QueryDict
1617
from django.urls import reverse
1718
from django.utils.translation import gettext as _
@@ -35,6 +36,7 @@
3536
)
3637
from lms.djangoapps.courseware.access_utils import check_authentication, check_data_sharing_consent, check_enrollment, \
3738
check_correct_active_enterprise_customer, is_priority_access_error
39+
from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc
3840
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
3941
from lms.djangoapps.courseware.date_summary import (
4042
CertificateAvailableDate,
@@ -50,7 +52,9 @@
5052
from lms.djangoapps.courseware.masquerade import check_content_start_date_for_masquerade_user
5153
from lms.djangoapps.courseware.model_data import FieldDataCache
5254
from lms.djangoapps.courseware.block_render import get_block
55+
from lms.djangoapps.grades.api import CourseGradeFactory
5356
from lms.djangoapps.survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered
57+
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
5458
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
5559
from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details
5660
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
@@ -587,7 +591,7 @@ def get_course_blocks_completion_summary(course_key, user):
587591

588592

589593
@request_cached()
590-
def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements
594+
def get_course_assignments(course_key, user, include_access=False, include_without_due=False,): # lint-amnesty, pylint: disable=too-many-statements
591595
"""
592596
Returns a list of assignment (at the subsection/sequential level) due dates for the given course.
593597
@@ -607,7 +611,8 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne
607611
for subsection_key in block_data.get_children(section_key):
608612
due = block_data.get_xblock_field(subsection_key, 'due')
609613
graded = block_data.get_xblock_field(subsection_key, 'graded', False)
610-
if due and graded:
614+
615+
if (due or include_without_due) and graded:
611616
first_component_block_id = get_first_component_of_block(subsection_key, block_data)
612617
contains_gated_content = include_access and block_data.get_xblock_field(
613618
subsection_key, 'contains_gated_content', False)
@@ -624,7 +629,11 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne
624629
else:
625630
complete = False
626631

627-
past_due = not complete and due < now
632+
if due:
633+
past_due = not complete and due < now
634+
else:
635+
past_due = False
636+
due = None
628637
assignments.append(_Assignment(
629638
subsection_key, title, url, due, contains_gated_content,
630639
complete, past_due, assignment_type, None, first_component_block_id
@@ -701,6 +710,39 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne
701710
return assignments
702711

703712

713+
def get_assignments_grades(user, course_id, cache_timeout):
714+
"""
715+
Calculate the progress of the assignment for the user in the course.
716+
717+
Arguments:
718+
user (User): Django User object.
719+
course_id (CourseLocator): The course key.
720+
cache_timeout (int): Cache timeout in seconds
721+
Returns:
722+
list (ReadSubsectionGrade, ZeroSubsectionGrade): The list with assignments grades.
723+
"""
724+
is_staff = bool(has_access(user, 'staff', course_id))
725+
726+
try:
727+
course = get_course_with_access(user, 'load', course_id)
728+
cache_key = f'course_block_structure_{str(course_id)}_{str(course.course_version)}_{user.id}'
729+
collected_block_structure = cache.get(cache_key)
730+
if not collected_block_structure:
731+
collected_block_structure = get_block_structure_manager(course_id).get_collected()
732+
cache.set(cache_key, collected_block_structure, cache_timeout)
733+
734+
course_grade = CourseGradeFactory().read(user, collected_block_structure=collected_block_structure)
735+
736+
# recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not)
737+
course_grade.update(visible_grades_only=True, has_staff_access=is_staff)
738+
subsection_grades = list(course_grade.subsection_grades.values())
739+
except Exception as err: # pylint: disable=broad-except
740+
log.warning(f'Could not get grades for the course: {course_id}, error: {err}')
741+
return []
742+
743+
return subsection_grades
744+
745+
704746
def get_first_component_of_block(block_key, block_data):
705747
"""
706748
This function returns the first leaf block of a section(block_key)
@@ -956,3 +998,64 @@ def get_course_chapter_ids(course_key):
956998
log.exception('Failed to retrieve course from modulestore.')
957999
return []
9581000
return [str(chapter_key) for chapter_key in chapter_keys if chapter_key.block_type == 'chapter']
1001+
1002+
1003+
def get_past_and_future_course_assignments(request, user, course):
1004+
"""
1005+
Returns the future assignment data and past assignments data for given user and course.
1006+
1007+
Arguments:
1008+
request (Request): The HTTP GET request.
1009+
user (User): The user for whom the assignments are received.
1010+
course (Course): Course object for whom the assignments are received.
1011+
Returns:
1012+
tuple (list, list): Tuple of `past_assignments` list and `next_assignments` list.
1013+
`next_assignments` list contains only uncompleted assignments.
1014+
"""
1015+
assignments = get_course_assignment_date_blocks(course, user, request, include_past_dates=True)
1016+
past_assignments = []
1017+
future_assignments = []
1018+
1019+
timezone = get_user_timezone_or_last_seen_timezone_or_utc(user)
1020+
for assignment in sorted(assignments, key=lambda x: x.date):
1021+
if assignment.date < datetime.now(timezone):
1022+
past_assignments.append(assignment)
1023+
else:
1024+
if not assignment.complete:
1025+
future_assignments.append(assignment)
1026+
1027+
if future_assignments:
1028+
future_assignment_date = future_assignments[0].date.date()
1029+
next_assignments = [
1030+
assignment for assignment in future_assignments if assignment.date.date() == future_assignment_date
1031+
]
1032+
else:
1033+
next_assignments = []
1034+
1035+
return next_assignments, past_assignments
1036+
1037+
1038+
def get_assignments_completions(course_key, user):
1039+
"""
1040+
Calculate the progress of the user in the course by assignments.
1041+
1042+
Arguments:
1043+
course_key (CourseLocator): The Course for which course progress is requested.
1044+
user (User): The user for whom course progress is requested.
1045+
Returns:
1046+
dict (dict): Dictionary contains information about total assignments count
1047+
in the given course and how many assignments the user has completed.
1048+
"""
1049+
course_assignments = get_course_assignments(course_key, user, include_without_due=True)
1050+
1051+
total_assignments_count = 0
1052+
assignments_completed = 0
1053+
1054+
if course_assignments:
1055+
total_assignments_count = len(course_assignments)
1056+
assignments_completed = len([assignment for assignment in course_assignments if assignment.complete])
1057+
1058+
return {
1059+
'total_assignments_count': total_assignments_count,
1060+
'assignments_completed': assignments_completed,
1061+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Common constants for the `course_info` API.
3+
"""
4+
5+
BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour

lms/djangoapps/mobile_api/course_info/serializers.py

Lines changed: 10 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_assignments_completions
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,12 @@ 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]:
82+
"""
83+
Gets course progress calculated by course completed assignments.
84+
"""
85+
return get_assignments_completions(obj.id, self.context.get('user'))
86+
7887

7988
class MobileCourseEnrollmentSerializer(serializers.ModelSerializer):
8089
"""

0 commit comments

Comments
 (0)