diff --git a/lms/djangoapps/certificates/apis/v0/filters.py b/lms/djangoapps/certificates/apis/v0/filters.py new file mode 100644 index 000000000000..186d44fada77 --- /dev/null +++ b/lms/djangoapps/certificates/apis/v0/filters.py @@ -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'] diff --git a/lms/djangoapps/certificates/apis/v0/serializers.py b/lms/djangoapps/certificates/apis/v0/serializers.py new file mode 100644 index 000000000000..23fed943ae0c --- /dev/null +++ b/lms/djangoapps/certificates/apis/v0/serializers.py @@ -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') diff --git a/lms/djangoapps/certificates/apis/v0/urls.py b/lms/djangoapps/certificates/apis/v0/urls.py index 5a10dedc3f85..b136871f1e7f 100644 --- a/lms/djangoapps/certificates/apis/v0/urls.py +++ b/lms/djangoapps/certificates/apis/v0/urls.py @@ -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, diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index 121f37fe72d0..4a815cd83516 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -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 @@ -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 @@ -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 diff --git a/openedx/core/djangoapps/enrollments/paginators.py b/openedx/core/djangoapps/enrollments/paginators.py index e7534c05bc67..fbdd0401aadc 100644 --- a/openedx/core/djangoapps/enrollments/paginators.py +++ b/openedx/core/djangoapps/enrollments/paginators.py @@ -3,7 +3,7 @@ """ -from rest_framework.pagination import CursorPagination +from rest_framework.pagination import CursorPagination, PageNumberPagination class CourseEnrollmentsApiListPagination(CursorPagination): @@ -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' diff --git a/openedx/core/djangoapps/enrollments/serializers.py b/openedx/core/djangoapps/enrollments/serializers.py index 9fde7c04033a..7153a6a93115 100644 --- a/openedx/core/djangoapps/enrollments/serializers.py +++ b/openedx/core/djangoapps/enrollments/serializers.py @@ -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__) @@ -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') diff --git a/openedx/core/djangoapps/enrollments/urls.py b/openedx/core/djangoapps/enrollments/urls.py index 6b875ec72df0..df9e4be9376f 100644 --- a/openedx/core/djangoapps/enrollments/urls.py +++ b/openedx/core/djangoapps/enrollments/urls.py @@ -13,7 +13,8 @@ EnrollmentListView, EnrollmentUserRolesView, EnrollmentView, - UnenrollmentView + UnenrollmentView, + UsersCourseEnrollmentsApiListView, ) urlpatterns = [ @@ -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'), ] diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 8124db23ee9d..2bc565cf7fdf 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -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 @@ -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 @@ -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)