-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: [FC-0047] Extend mobile API with course progress and primary courses on dashboard view #34848
Changes from 16 commits
348fdc3
96f951b
8644c21
c4db96e
b348cb5
b64a690
aaa1b2d
9a9ce41
8accb6d
88d31ae
faa1fc4
0ff9f55
8fa5ef3
7aa27c2
7c53063
eee5e7b
12a6866
cfa816c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -129,11 +129,71 @@ 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, user_username): | ||
""" | ||
Returns a queryset of CourseEnrollment objects for courses that do not have a certificate. | ||
""" | ||
from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel | ||
course_ids_with_certificates = GeneratedCertificate.objects.filter( | ||
user__username=user_username | ||
).values_list('course_id', flat=True) | ||
return self.exclude(course_id__in=course_ids_with_certificates) | ||
|
||
def with_certificates(self, user_username): | ||
""" | ||
Returns a queryset of CourseEnrollment objects for courses that have a certificate. | ||
""" | ||
from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel | ||
course_ids_with_certificates = GeneratedCertificate.objects.filter( | ||
user__username=user_username | ||
).values_list('course_id', flat=True) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These two lines are duplicated, can we write a method for user_course_ids_with_certificates? |
||
return self.filter(course_id__in=course_ids_with_certificates) | ||
|
||
def in_progress(self, user_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(user_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, user_username): | ||
""" | ||
Returns a queryset of CourseEnrollment objects for courses that have been completed. | ||
""" | ||
return self.active().with_certificates(user_username) | ||
|
||
def expired(self, user_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(user_username).filter(course__end__lt=now) | ||
|
||
|
||
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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ | |
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_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.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,38 @@ 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: | ||
cache_key = f'course_block_structure_{str(course_id)}_{user.id}' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we should use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, added cache invalidation with course version |
||
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 +1060,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, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
""" | ||
Common constants for the `course_info` API. | ||
""" | ||
|
||
BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
username is enough.