Skip to content

Commit

Permalink
Merge pull request #450 from edly-io/feat/add-progress-and-completion…
Browse files Browse the repository at this point in the history
…-apis

feat: add progress list API and Course completion API
  • Loading branch information
hinakhadim authored Nov 24, 2023
2 parents cf00324 + 75f2ac8 commit c6113f3
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 5 deletions.
12 changes: 12 additions & 0 deletions lms/djangoapps/certificates/apis/v0/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import django_filters
from lms.djangoapps.certificates.models import GeneratedCertificate


class GeneratedCertificateFilter(django_filters.FilterSet):
created_date = django_filters.DateFilter(field_name='created_date', lookup_expr='date')
date_gt = django_filters.DateFilter(field_name='created_date', lookup_expr='gt')
date_lt = django_filters.DateFilter(field_name='created_date', lookup_expr='lt')

class Meta:
model = GeneratedCertificate
fields = ['created_date', 'date_gt', 'date_lt']
27 changes: 27 additions & 0 deletions lms/djangoapps/certificates/apis/v0/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from rest_framework import serializers

from lms.djangoapps.certificates.models import GeneratedCertificate

from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.enrollments.serializers import CourseSerializer
from openedx.core.djangoapps.user_api.serializers import UserSerializer

class GeneratedCertificateSerializer(serializers.ModelSerializer):
"""Serializers have an abstract create & update, but we often don't need them. So this silences the linter."""

course_info = serializers.SerializerMethodField()
user = UserSerializer()

def get_course_info(self, obj):
try:
self._course_overview = CourseOverview.get_from_id(obj.course_id)
if self._course_overview:
self._course_overview = CourseSerializer(self._course_overview).data
except (CourseOverview.DoesNotExist, OSError):
self._course_overview = None
return self._course_overview


class Meta:
model = GeneratedCertificate
fields = ('user', 'course_id', 'created_date', 'grade', 'key', 'status', 'mode', 'name', 'course_info')
1 change: 1 addition & 0 deletions lms/djangoapps/certificates/apis/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from lms.djangoapps.certificates.apis.v0 import views

CERTIFICATES_URLS = ([
re_path(r'^completion/$', views.CertificatesCompletionView.as_view(), name='certificate_completion'),
re_path(
r'^{username}/courses/{course_id}/$'.format(
username=settings.USERNAME_PATTERN,
Expand Down
45 changes: 44 additions & 1 deletion lms/djangoapps/certificates/apis/v0/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@

import edx_api_doc_tools as apidocs
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.permissions import IsAuthenticated
from rest_framework import filters
from rest_framework.generics import ListAPIView
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView

Expand All @@ -19,12 +23,15 @@
get_certificate_for_user,
get_certificates_for_user
)
from lms.djangoapps.certificates.apis.v0.filters import GeneratedCertificateFilter
from lms.djangoapps.certificates.apis.v0.permissions import IsOwnerOrPublicCertificates
from lms.djangoapps.certificates.apis.v0.serializers import GeneratedCertificateSerializer
from openedx.core.djangoapps.content.course_overviews.api import (
get_course_overview_or_none,
get_course_overviews_from_ids,
get_pseudo_course_overview
)
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.user_api.accounts.api import visible_fields
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser

Expand Down Expand Up @@ -293,3 +300,39 @@ def _get_certificates_for_user(self, username):

viewable_certificates.sort(key=lambda certificate: certificate['created'])
return viewable_certificates


class CertificatesCompletionView(ListAPIView):
authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (
IsAdminUser,
permissions.IsStaff,
)

queryset = GeneratedCertificate.eligible_certificates.all()
serializer_class = GeneratedCertificateSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = GeneratedCertificateFilter
ordering_fields = ('created_date', )
ordering = ('-created_date', )
paginate_by = 10
paginate_by_param = "page_size"

def get_queryset(self):
queryset = super().get_queryset()
course_id = self.request.query_params.get('course_id', None)

if course_id:
try:
CourseKey.from_string(course_id)
except InvalidKeyError:
# lint-amnesty, pylint: disable=raise-missing-from
raise ValidationError(f"'{course_id}' is not a valid course id.")

queryset = queryset.filter(course_id=course_id)

return queryset
9 changes: 8 additions & 1 deletion openedx/core/djangoapps/enrollments/paginators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""


from rest_framework.pagination import CursorPagination
from rest_framework.pagination import CursorPagination, PageNumberPagination


class CourseEnrollmentsApiListPagination(CursorPagination):
Expand All @@ -14,3 +14,10 @@ class CourseEnrollmentsApiListPagination(CursorPagination):
page_size_query_param = 'page_size'
max_page_size = 100
page_query_param = 'page'


class UsersCourseEnrollmentsApiPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
page_query_param = 'page'
12 changes: 12 additions & 0 deletions openedx/core/djangoapps/enrollments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment

from lms.djangoapps.course_home_api.progress.serializers import CourseGradeSerializer

log = logging.getLogger(__name__)


Expand Down Expand Up @@ -127,3 +129,13 @@ class ModeSerializer(serializers.Serializer): # pylint: disable=abstract-method
description = serializers.CharField()
sku = serializers.CharField()
bulk_sku = serializers.CharField()


class UsersCourseEnrollmentSerializer(serializers.Serializer):

completion_summary = serializers.DictField()
progress = serializers.FloatField()
course_grade = CourseGradeSerializer()
enrollment_mode = serializers.CharField()
user_has_passing_grade = serializers.BooleanField()
course_enrollment = CourseEnrollmentsApiListSerializer(source='enrollment')
4 changes: 3 additions & 1 deletion openedx/core/djangoapps/enrollments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
EnrollmentListView,
EnrollmentUserRolesView,
EnrollmentView,
UnenrollmentView
UnenrollmentView,
UsersCourseEnrollmentsApiListView,
)

urlpatterns = [
Expand All @@ -29,4 +30,5 @@
EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'),
path('unenroll/', UnenrollmentView.as_view(), name='unenrollment'),
path('roles/', EnrollmentUserRolesView.as_view(), name='roles'),
path('enrollment-admin/', UsersCourseEnrollmentsApiListView.as_view(), name='userscoursesenrollmentsapilist'),
]
186 changes: 184 additions & 2 deletions openedx/core/djangoapps/enrollments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
from common.djangoapps.student.models import CourseEnrollment, User
from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff
from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary, get_course_with_access
from lms.djangoapps.grades.api import CourseGradeFactory
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from openedx.core.djangoapps.course_groups.cohorts import CourseUserGroup, add_user_to_cohort, get_cohort_by_name
Expand All @@ -40,8 +44,8 @@
CourseModeNotFoundError
)
from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm
from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination
from openedx.core.djangoapps.enrollments.serializers import CourseEnrollmentsApiListSerializer
from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination, UsersCourseEnrollmentsApiPagination
from openedx.core.djangoapps.enrollments.serializers import CourseEnrollmentsApiListSerializer, UsersCourseEnrollmentSerializer
from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
Expand Down Expand Up @@ -987,3 +991,181 @@ def get_queryset(self):
if usernames:
queryset = queryset.filter(user__username__in=usernames)
return queryset


@can_disable_rate_limit
class UsersCourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView):
"""
**Use Cases**
Get a list of all course enrollments, optionally filtered by a course ID or list of usernames.
**Example Requests**
GET /api/enrollment/v1/enrollments
GET /api/enrollment/v1/enrollments?course_id={course_id}
GET /api/enrollment/v1/enrollments?username={username},{username},{username}
GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username}
**Query Parameters for GET**
* course_id: Filters the result to course enrollments for the course corresponding to the
given course ID. The value must be URL encoded. Optional.
* username: List of comma-separated usernames. Filters the result to the course enrollments
of the given users. Optional.
* page_size: Number of results to return per page. Optional.
* page: Page number to retrieve. Optional.
**Response Values**
If the request for information about the course enrollments is successful, an HTTP 200 "OK" response
is returned.
The HTTP 200 response has the following values.
* results: A list of the course enrollments matching the request.
* completion_summary: Object containing unit completion counts with the following fields:
complete_count: (float) number of complete units
incomplete_count: (float) number of incomplete units
locked_count: (float) number of units where contains_gated_content is True
* course_grade: Object containing the following fields:
is_passing: (bool) whether the user's grade is above the passing grade cutoff
letter_grade: (str) the user's letter grade based on the set grade range.
If user is passing, value may be 'A', 'B', 'C', 'D', 'Pass', otherwise none
percent: (float) the user's total graded percent in the course
* progress: User's Progress of course
* enrollment_mode: (str) a str representing the enrollment the user has ('audit', 'verified', ...)
* user_has_passing_grade: (bool) boolean on if the user has a passing grade in the course
* created: Date and time when the course enrollment was created.
* mode: Mode for the course enrollment.
* is_active: Whether the course enrollment is active or not.
* user: Username of the user in the course enrollment.
* course_id: Course ID of the course in the course enrollment.
* next: The URL to the next page of results, or null if this is the
last page.
* previous: The URL to the next page of results, or null if this
is the first page.
If the user is not logged in, a 401 error is returned.
If the user is not global staff, a 403 error is returned.
If the specified course_id is not valid or any of the specified usernames
are not valid, a 400 error is returned.
If the specified course_id does not correspond to a valid course or if all the specified
usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an
empty 'results' field.
"""
authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.IsAdminUser,)
throttle_classes = (EnrollmentUserThrottle,)
serializer_class = UsersCourseEnrollmentSerializer
pagination_class = UsersCourseEnrollmentsApiPagination

def get_queryset(self):
"""
Get all the course enrollments for the given course_id and/or given list of usernames with learner's progress.
"""
form = CourseEnrollmentsApiListForm(self.request.query_params)

if not form.is_valid():
raise ValidationError(form.errors)

queryset = CourseEnrollment.objects.all()
course_id = form.cleaned_data.get('course_id')
usernames = form.cleaned_data.get('username')

if course_id:
queryset = queryset.filter(course_id=course_id)
if usernames:
queryset = queryset.filter(user__username__in=usernames)

return self.paginate_queryset(queryset)

def list(self, request):
# Note the use of `get_queryset()` instead of `self.queryset`
enrollments = self.get_queryset()

response = []

for enrollment in enrollments:
is_staff = bool(has_access(enrollment.user, 'staff', enrollment.course))
course = get_course_with_access(enrollment.user, 'load', enrollment.course.id, check_if_enrolled=False)

student = enrollment.user
course_key = enrollment.course.id

enrollment_mode = getattr(enrollment, 'mode', None)

if not (enrollment and enrollment.is_active) and not is_staff:
# User not enrolled
continue

# The block structure is used for both the course_grade and has_scheduled content fields
# So it is called upfront and reused for optimization purposes
collected_block_structure = get_block_structure_manager(
enrollment.course.id).get_collected()
course_grade = CourseGradeFactory().read(
student, 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)

# Get user_has_passing_grade data
user_has_passing_grade = False
if not student.is_anonymous:
user_grade = course_grade.percent
user_has_passing_grade = user_grade >= course.lowest_passing_grade

completion_summary = get_course_blocks_completion_summary(course_key, student)
total_units = sum(completion_summary.values())
total_units = total_units if total_units > 0 else 1

data = {
'completion_summary': completion_summary,
'progress': "{:.2f}".format(completion_summary['complete_count'] / total_units),
'course_grade': course_grade,
'enrollment_mode': enrollment_mode,
'user_has_passing_grade': user_has_passing_grade,
'enrollment': enrollment
}

context = self.get_serializer_context()
context['staff_access'] = is_staff
context['course_key'] = course_key
serializer = self.get_serializer_class()(data, context=context)
serializer_data = serializer.data
course_enrollment = serializer_data.pop('course_enrollment')

user_progress = {}
user_progress.update(serializer_data)
user_progress.update(course_enrollment)

response.append(user_progress)

return self.get_paginated_response(response)

0 comments on commit c6113f3

Please sign in to comment.