From 99f73312b86f5b537c1f2d88a4fed1f778f19325 Mon Sep 17 00:00:00 2001 From: Carla Duarte Date: Fri, 12 Feb 2021 15:33:26 -0500 Subject: [PATCH] Progress Tab --- .../progress/v1/serializers.py | 85 ++++++----------- .../progress/v1/tests/test_views.py | 35 ++++--- .../course_home_api/progress/v1/views.py | 93 +++++++++---------- lms/djangoapps/courseware/courses.py | 35 +++++++ .../courseware_api/tests/test_views.py | 4 - 5 files changed, 123 insertions(+), 129 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/v1/serializers.py b/lms/djangoapps/course_home_api/progress/v1/serializers.py index bc6895d11b..50c7931150 100644 --- a/lms/djangoapps/course_home_api/progress/v1/serializers.py +++ b/lms/djangoapps/course_home_api/progress/v1/serializers.py @@ -5,20 +5,18 @@ from rest_framework import serializers from rest_framework.reverse import reverse -class GradedTotalSerializer(serializers.Serializer): - earned = serializers.FloatField() - possible = serializers.FloatField() +class CourseGradeSerializer(serializers.Serializer): + percent = serializers.FloatField() + is_passing = serializers.BooleanField(source='passed') -class SubsectionSerializer(serializers.Serializer): +class SubsectionScoresSerializer(serializers.Serializer): + assignment_type = serializers.CharField(source='format') display_name = serializers.CharField() - due = serializers.DateTimeField() - format = serializers.CharField() - graded = serializers.BooleanField() - graded_total = GradedTotalSerializer() - # TODO: override serializer + has_graded_assignment = serializers.BooleanField(source='graded') + num_points_earned = serializers.IntegerField(source='graded_total.earned') + num_points_possible = serializers.IntegerField(source='graded_total.possible') percent_graded = serializers.FloatField() - problem_scores = serializers.SerializerMethodField() show_correctness = serializers.CharField() show_grades = serializers.SerializerMethodField() url = serializers.SerializerMethodField() @@ -28,64 +26,33 @@ class SubsectionSerializer(serializers.Serializer): request = self.context['request'] return request.build_absolute_uri(relative_path) - def get_problem_scores(self, subsection): - problem_scores = [ - { - 'earned': score.earned, - 'possible': score.possible, - } - for score in subsection.problem_scores.values() - ] - return problem_scores - def get_show_grades(self, subsection): return subsection.show_grades(self.context['staff_access']) -class ChapterSerializer(serializers.Serializer): +class SectionScoresSerializer(serializers.Serializer): """ - Serializer for chapters in coursewaresummary + Serializer for chapters in courseware_summary """ display_name = serializers.CharField() - subsections = SubsectionSerializer(source='sections', many=True) + subsections = SubsectionScoresSerializer(source='sections', many=True) + + +class GradingPolicySerializer(serializers.Serializer): + assignment_policies = serializers.SerializerMethodField() + grade_range = serializers.DictField(source='GRADE_CUTOFFS') + + def get_assignment_policies(self, grading_policy): + return [{ + 'type': assignment_policy['type'], + 'weight': assignment_policy['weight'], + } for assignment_policy in grading_policy['GRADER']] class CertificateDataSerializer(serializers.Serializer): cert_status = serializers.CharField() cert_web_view_url = serializers.CharField() download_url = serializers.CharField() - msg = serializers.CharField() - title = serializers.CharField() - - -class CreditRequirementSerializer(serializers.Serializer): - """ - Serializer for credit requirement objects - """ - display_name = serializers.CharField() - min_grade = serializers.SerializerMethodField() - status = serializers.CharField() - status_date = serializers.DateTimeField() - - def get_min_grade(self, requirement): - if requirement['namespace'] == 'grade': - return requirement['criteria']['min_grade'] * 100 - else: - return None - - -class CreditCourseRequirementsSerializer(serializers.Serializer): - """ - Serializer for credit_course_requirements - """ - dashboard_url = serializers.SerializerMethodField() - eligibility_status = serializers.CharField() - requirements = CreditRequirementSerializer(many=True) - - def get_dashboard_url(self, _): - relative_path = reverse('dashboard') - request = self.context['request'] - return request.build_absolute_uri(relative_path) class VerificationDataSerializer(serializers.Serializer): @@ -102,10 +69,10 @@ class ProgressTabSerializer(serializers.Serializer): Serializer for progress tab """ certificate_data = CertificateDataSerializer() - credit_course_requirements = CreditCourseRequirementsSerializer() - credit_support_url = serializers.URLField() - courseware_summary = ChapterSerializer(many=True) + completion_summary = serializers.DictField() + course_grade = CourseGradeSerializer() + section_scores = SectionScoresSerializer(many=True) enrollment_mode = serializers.CharField() + grading_policy = GradingPolicySerializer() studio_url = serializers.CharField() - user_timezone = serializers.CharField() verification_data = VerificationDataSerializer() diff --git a/lms/djangoapps/course_home_api/progress/v1/tests/test_views.py b/lms/djangoapps/course_home_api/progress/v1/tests/test_views.py index f157c53678..889a247605 100644 --- a/lms/djangoapps/course_home_api/progress/v1/tests/test_views.py +++ b/lms/djangoapps/course_home_api/progress/v1/tests/test_views.py @@ -33,12 +33,10 @@ class ProgressTabTestViews(BaseCourseHomeTests): response = self.client.get(self.url) assert response.status_code == 200 - # Pulling out the courseware summary to check that the learner is able to see this info - assert response.data['courseware_summary'] is not None - for chapter in response.data['courseware_summary']: + assert response.data['section_scores'] is not None + for chapter in response.data['section_scores']: assert chapter is not None assert ('settings/grading/' + str(self.course.id)) in response.data['studio_url'] - assert response.data['credit_support_url'] == CREDIT_SUPPORT_URL assert response.data['verification_data'] is not None assert response.data['verification_data']['status'] == 'none' if enrollment_mode == CourseMode.VERIFIED: @@ -57,23 +55,24 @@ class ProgressTabTestViews(BaseCourseHomeTests): def test_get_unauthenticated_user(self): self.client.logout() response = self.client.get(self.url) - assert response.status_code == 403 + assert response.status_code == 401 def test_get_unknown_course(self): url = reverse('course-home-progress-tab', args=['course-v1:unknown+course+2T2020']) response = self.client.get(url) assert response.status_code == 404 - def test_masquerade(self): - user = UserFactory() - set_user_preference(user, 'time_zone', 'Asia/Tokyo') - CourseEnrollment.enroll(user, self.course.id) - - self.switch_to_staff() # needed for masquerade - - # Sanity check on our normal user - assert self.client.get(self.url).data['user_timezone'] is None - - # Now switch users and confirm we get a different result - self.update_masquerade(username=user.username) - assert self.client.get(self.url).data['user_timezone'] == 'Asia/Tokyo' + # TODO: (AA-212) implement masquerade + # def test_masquerade(self): + # user = UserFactory() + # set_user_preference(user, 'time_zone', 'Asia/Tokyo') + # CourseEnrollment.enroll(user, self.course.id) + # + # self.switch_to_staff() # needed for masquerade + # + # # Sanity check on our normal user + # assert self.client.get(self.url).data['user_timezone'] is None + # + # # Now switch users and confirm we get a different result + # self.update_masquerade(username=user.username) + # assert self.client.get(self.url).data['user_timezone'] == 'Asia/Tokyo' diff --git a/lms/djangoapps/course_home_api/progress/v1/views.py b/lms/djangoapps/course_home_api/progress/v1/views.py index 119b49a87b..19df71bd9b 100644 --- a/lms/djangoapps/course_home_api/progress/v1/views.py +++ b/lms/djangoapps/course_home_api/progress/v1/views.py @@ -3,25 +3,24 @@ Progress Tab Views """ from edx_django_utils import monitoring as monitoring_utils +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys.edx.keys import CourseKey from rest_framework.generics import RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -import lms.djangoapps.course_blocks.api as course_blocks_api +from xmodule.modulestore.django import modulestore from common.djangoapps.student.models import CourseEnrollment -from lms.djangoapps.course_api.blocks.transformers.blocks_api import BlocksAPITransformer from lms.djangoapps.course_home_api.progress.v1.serializers import ProgressTabSerializer from lms.djangoapps.courseware.access import has_access -from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs -from lms.djangoapps.courseware.courses import get_course_with_access, get_studio_url +from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary, get_course_with_access, get_studio_url from lms.djangoapps.courseware.masquerade import setup_masquerade -from lms.djangoapps.courseware.views.views import credit_course_requirements, get_cert_data +from lms.djangoapps.courseware.views.views import get_cert_data + from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.verify_student.services import IDVerificationService -from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers - -CREDIT_SUPPORT_URL = 'https://support.edx.org/hc/en-us/sections/115004154688-Purchasing-Academic-Credit' +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser class ProgressTabView(RetrieveAPIView): @@ -39,54 +38,58 @@ class ProgressTabView(RetrieveAPIView): Body consists of the following fields: certificate_data: Object containing information about the user's certificate status + cert_status: (str) the status of a user's certificate (full list of statuses are defined in + lms/djangoapps/certificates/models.py) cert_web_view_url: (str) the url to view the certificate download_url: (str) the url to download the certificate - is_downloadable: (bool) true if the status is downloadable and the download url is not None - is_requestable: (bool) true if status is requesting and request_cert_url is not None - msg: (str) message for the certificate status - title: (str) title of the certificate status - credit_course_requirements: An object containing the following fields - dashboard_url: (str) the url to the user's dashboard - eligibility_status: (str) the user's eligibility to receive a course credit - requirements: object containing the following fields - display_name: (str) the name of the requirement that should be displayed - namespace: (str) the type that the requirement is - min_grade: (float) the percentage formatted minimum grade needed for this requirement - status: (str) the status of the requirement - status_date: (str) the date the status was set - credit_support_url: (str) the url to the support docs for purchasing a credit - courseware_summary: List of serialized Chapters. each Chapter has the following fields: + 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: + percent: (float) the user's total graded percent in the course + is_passing: (bool) whether the user's grade is above the passing grade cutoff + graded_course_blocks: List of serialized Chapters. Each Chapter has the following fields: display_name: (str) a str of what the name of the Chapter is for displaying on the site subsections: List of serialized Subsections, each has the following fields: + assignment_type: (str) the format, if any, of the Subsection (Homework, Exam, etc) display_name: (str) a str of what the name of the Subsection is for displaying on the site - due: (str) a DateTime string for when the Subsection is due - format: (str) the format, if any, of the Subsection (Homework, Exam, etc) - graded: (bool) whether or not the Subsection is graded - graded_total: an object containing the following fields - earned: (float) the amount of points the user earned - possible: (float) the amount of points the user could have earned - percent_graded: (float) the percentage of the points the user received for the subsection + has_graded_assignment: (bool) whether or not the Subsection is a graded assignment + num_points_earned: (int) the amount of points the user has earned for the given subsection + num_points_possible: (int) the total amount of points possible for the given subsection + percent_graded: (float) the percentage of total points the user has received a grade for in a given subsection show_correctness: (str) a str representing whether to show the problem/practice scores based on due date + ('always', 'never', 'past_due', values defined in + common/lib/xmodule/xmodule/modulestore/inheritance.py) show_grades: (bool) a bool for whether to show grades based on the access the user has url: (str) the absolute path url to the Subsection enrollment_mode: (str) a str representing the enrollment the user has ('audit', 'verified', ...) + grading_policy: + assignment_policies: List of serialized assignment grading policy objects, each has the following fields: + type: (str) the assignment type + weight: (float) the percent weight the given assigment type has on the overall grade + grade_range: an object containing the grade range cutoffs. The exact keys in the object can vary, but they + range from just 'Pass', to a combination of 'A', 'B', 'C', and 'D'. If a letter grade is present, + 'Pass' is not included. studio_url: (str) a str of the link to the grading in studio for the course - user_timezone: (str) The user's preferred timezone verification_data: an object containing link: (str) the link to either start or retry verification status: (str) the status of the verification status_date: (str) the date time string of when the verification status was set - - **Returns** * 200 on success with above fields. * 302 if the user is not enrolled. - * 403 if the user is not authenticated. + * 401 if the user is not authenticated. * 404 if the course is not available or cannot be seen. """ + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) permission_classes = (IsAuthenticated,) serializer_class = ProgressTabSerializer @@ -106,20 +109,14 @@ class ProgressTabView(RetrieveAPIView): reset_masquerade_data=True ) - user_timezone_locale = user_timezone_locale_prefs(request) - user_timezone = user_timezone_locale['user_timezone'] - - transformers = BlockStructureTransformers() - transformers += course_blocks_api.get_course_block_access_transformers(request.user) - transformers += [ - BlocksAPITransformer(None, None, depth=3), - ] course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user(request.user, course_key) - course_grade = CourseGradeFactory().read(request.user, course) - courseware_summary = course_grade.chapter_grades.values() + course_grade = CourseGradeFactory().read(request.user, course)\ + + descriptor = modulestore().get_course(course_key) + grading_policy = descriptor.grading_policy verification_status = IDVerificationService.user_status(request.user) verification_link = None @@ -135,12 +132,12 @@ class ProgressTabView(RetrieveAPIView): data = { 'certificate_data': get_cert_data(request.user, course, enrollment_mode, course_grade), - 'courseware_summary': courseware_summary, - 'credit_course_requirements': credit_course_requirements(course_key, request.user), - 'credit_support_url': CREDIT_SUPPORT_URL, + 'completion_summary': get_course_blocks_completion_summary(course_key, request.user), + 'course_grade': course_grade, + 'section_scores': course_grade.chapter_grades.values(), 'enrollment_mode': enrollment_mode, + 'grading_policy': grading_policy, 'studio_url': get_studio_url(course, 'settings/grading'), - 'user_timezone': user_timezone, 'verification_data': verification_data, } context = self.get_serializer_context() diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index ea038b1b0b..48e1790f22 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -519,6 +519,41 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, return date_blocks +@request_cached() +def get_course_blocks_completion_summary(course_key, user): + """ + Returns an object with the number of complete units, incomplete units, and units that contain gated content + for the given course. The complete and incomplete counts only reflect units that are able to be completed by + the given user. If a unit contains gated content, it is not counted towards the incomplete count. + + The object contains fields: complete_count, incomplete_count, locked_count + """ + if not user.id: + return [] + store = modulestore() + course_usage_key = store.make_course_usage_key(course_key) + block_data = get_course_blocks(user, course_usage_key, allow_start_dates_in_future=True, include_completion=True) + + complete_count, incomplete_count, locked_count = 0, 0, 0 + for section_key in block_data.get_children(course_usage_key): # pylint: disable=too-many-nested-blocks + for subsection_key in block_data.get_children(section_key): + for unit_key in block_data.get_children(subsection_key): + complete = block_data.get_xblock_field(unit_key, 'complete', False) + contains_gated_content = block_data.get_xblock_field(unit_key, 'contains_gated_content', False) + if contains_gated_content: + locked_count += 1 + elif complete: + complete_count += 1 + else: + incomplete_count += 1 + + return { + 'complete_count': complete_count, + 'incomplete_count': incomplete_count, + 'locked_count': locked_count + } + + @request_cached() def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements """ diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index c73507e1b2..a5bf56d01f 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -137,10 +137,6 @@ class CourseApiTestViews(BaseCoursewareTests): assert not response.data['user_has_passing_grade'] if enrollment_mode == 'audit': - # This message comes from AUDIT_PASSING_CERT_DATA in lms/djangoapps/courseware/views/views.py - expected_audit_message = ('You are enrolled in the audit track for this course. ' - 'The audit track does not include a certificate.') - assert response.data['certificate_data']['msg'] == expected_audit_message assert response.data['verify_identity_url'] is None assert response.data['verification_status'] == 'none' # lint-amnesty, pylint: disable=literal-comparison assert response.data['linkedin_add_to_profile_url'] is None