From f557e395de41a0a3916235ab9bbbf7be97c6909a Mon Sep 17 00:00:00 2001 From: Daphne Li-Chen Date: Thu, 2 Jul 2020 09:53:51 -0400 Subject: [PATCH] AA-274: sending credit requirement information and added tests --- .../progress/v1/serializers.py | 38 ++++++-- .../progress/v1/tests/test_views.py | 10 +++ .../course_home_api/progress/v1/views.py | 36 +++++++- lms/djangoapps/verify_student/services.py | 3 + .../verify_student/tests/test_services.py | 87 +++++++++++-------- 5 files changed, 132 insertions(+), 42 deletions(-) diff --git a/lms/djangoapps/course_home_api/progress/v1/serializers.py b/lms/djangoapps/course_home_api/progress/v1/serializers.py index 375d9daa78..09283799b5 100644 --- a/lms/djangoapps/course_home_api/progress/v1/serializers.py +++ b/lms/djangoapps/course_home_api/progress/v1/serializers.py @@ -66,18 +66,43 @@ class CertificateDataSerializer(serializers.Serializer): return cert_data.cert_status == CertificateStatuses.requesting and cert_data.request_cert_url is not None -class CreditCourseRequirementsSerializer(serializers.Serializer): +class CreditRequirementSerializer(serializers.Serializer): """ - Serializer for credit_course_requirements + Serializer for credit requirement objects """ display_name = serializers.CharField() - namespace = serializers.CharField() min_grade = serializers.SerializerMethodField() status = serializers.CharField() status_date = serializers.DateTimeField() - def get_min_grade(self, req): - return req['criteria']['min_grade'] * 100 + 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): + """ + Serializer for verification data object + """ + link = serializers.URLField() + status = serializers.CharField() + status_date = serializers.DateTimeField() class ProgressTabSerializer(serializers.Serializer): @@ -85,7 +110,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) enrollment_mode = serializers.CharField() 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 5e6e89c4c4..a30d34208e 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 @@ -9,11 +9,14 @@ from django.urls import reverse from course_modes.models import CourseMode from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.course_home_api.toggles import COURSE_HOME_MICROFRONTEND +from lms.djangoapps.verify_student.models import ManualVerification from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from student.models import CourseEnrollment from student.tests.factories import UserFactory +CREDIT_SUPPORT_URL = 'https://support.edx.org/hc/en-us/sections/115004154688-Purchasing-Academic-Credit' + @override_waffle_flag(COURSE_HOME_MICROFRONTEND, active=True) @ddt.ddt @@ -36,6 +39,13 @@ class ProgressTabTestViews(BaseCourseHomeTests): for chapter in response.data['courseware_summary']: self.assertIsNotNone(chapter) self.assertIn('settings/grading/' + str(self.course.id), response.data['studio_url']) + self.assertEqual(response.data['credit_support_url'], CREDIT_SUPPORT_URL) + self.assertIsNotNone(response.data['verification_data']) + self.assertEqual(response.data['verification_data']['status'], 'none') + if enrollment_mode == CourseMode.VERIFIED: + ManualVerification.objects.create(user=self.user, status='approved') + response = self.client.get(self.url) + self.assertEqual(response.data['verification_data']['status'], 'approved') def test_get_authenticated_user_not_enrolled(self): response = self.client.get(self.url) diff --git a/lms/djangoapps/course_home_api/progress/v1/views.py b/lms/djangoapps/course_home_api/progress/v1/views.py index 602c0977f7..6aa5fdaadb 100644 --- a/lms/djangoapps/course_home_api/progress/v1/views.py +++ b/lms/djangoapps/course_home_api/progress/v1/views.py @@ -17,13 +17,15 @@ from lms.djangoapps.courseware.context_processor import user_timezone_locale_pre from lms.djangoapps.courseware.courses import get_course_with_access, get_studio_url from lms.djangoapps.courseware.masquerade import setup_masquerade from lms.djangoapps.courseware.access import has_access -from xmodule.modulestore.django import modulestore import lms.djangoapps.course_blocks.api as course_blocks_api from lms.djangoapps.courseware.views.views import credit_course_requirements, 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' + class ProgressTabView(RetrieveAPIView): """ @@ -46,6 +48,16 @@ class ProgressTabView(RetrieveAPIView): 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: 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: @@ -63,6 +75,10 @@ class ProgressTabView(RetrieveAPIView): enrollment_mode: (str) a str representing the enrollment the user has ('audit', 'verified', ...) 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 @@ -80,7 +96,6 @@ class ProgressTabView(RetrieveAPIView): def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) - course_usage_key = modulestore().make_course_usage_key(course_key) # Enable NR tracing for this view based on course monitoring_utils.set_custom_metric('course_id', course_key_string) @@ -109,12 +124,29 @@ class ProgressTabView(RetrieveAPIView): course_grade = CourseGradeFactory().read(request.user, course) courseware_summary = course_grade.chapter_grades.values() + verification_status = IDVerificationService.user_status(request.user) + verification_link = None + if verification_status['status'] is None or verification_status['status'] == 'expired': + verification_link = IDVerificationService.get_verify_location('verify_student_verify_now', + course_id=course_key) + elif verification_status['status'] == 'must_reverify': + verification_link = IDVerificationService.get_verify_location('verify_student_reverify', + course_id=course_key) + verification_data = { + 'link': verification_link, + 'status': verification_status['status'], + 'status_date': verification_status['status_date'], + } + 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, 'enrollment_mode': enrollment_mode, 'studio_url': get_studio_url(course, 'settings/grading'), 'user_timezone': user_timezone, + 'verification_data': verification_data, } context = self.get_serializer_context() context['staff_access'] = bool(has_access(request.user, 'staff', course)) diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index a12737246d..f1bfa0444b 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -173,6 +173,7 @@ class IDVerificationService(object): 'status': 'none', 'error': '', 'should_display': True, + 'status_date': '', 'verification_expiry': '', } @@ -188,6 +189,7 @@ class IDVerificationService(object): manual_id_verifications, 'updated_at' ) + except IndexError: # The user has no verification attempts, return the default set of data. return user_status @@ -218,6 +220,7 @@ class IDVerificationService(object): expiration_datetime = cls.get_expiration_datetime(user, ['approved']) if getattr(attempt, 'expiry_date', None) and is_verification_expiring_soon(expiration_datetime): user_status['verification_expiry'] = attempt.expiry_date.date().strftime("%m/%d/%Y") + user_status['status_date'] = attempt.status_changed elif attempt.status in ['submitted', 'approved', 'must_retry']: # user_has_valid_or_pending does include 'approved', but if we are diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 2e0a881436..548a85c8b6 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -3,10 +3,13 @@ Tests for the service classes in verify_student. """ +from datetime import datetime import ddt from django.conf import settings from mock import patch +from freezegun import freeze_time +from pytz import utc from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification from lms.djangoapps.verify_student.services import IDVerificationService @@ -66,47 +69,62 @@ class TestIDVerificationService(ModuleStoreTestCase): self.assertTrue(IDVerificationService.user_has_valid_or_pending(user), status) def test_user_status(self): + # each part of the test is in it's own 'frozen time' block because the status is dependent on recency of + # verifications and in order to control the recency, we just put everything inside of a frozen time + # test for correct status when no error returned user = UserFactory.create() - status = IDVerificationService.user_status(user) - expected_status = {'status': 'none', 'error': '', 'should_display': True, 'verification_expiry': ''} - self.assertDictEqual(status, expected_status) + with freeze_time('2014-12-12'): + status = IDVerificationService.user_status(user) + expected_status = {'status': 'none', 'error': '', 'should_display': True, 'verification_expiry': '', + 'status_date': ''} + self.assertDictEqual(status, expected_status) - # test for when photo verification has been created - SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') - status = IDVerificationService.user_status(user) - expected_status = {'status': 'approved', 'error': '', 'should_display': True, 'verification_expiry': ''} - self.assertDictEqual(status, expected_status) + with freeze_time('2015-01-02'): + # test for when photo verification has been created + SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') + status = IDVerificationService.user_status(user) + expected_status = {'status': 'approved', 'error': '', 'should_display': True, 'verification_expiry': '', + 'status_date': datetime.now(utc)} + self.assertDictEqual(status, expected_status) - # create another photo verification for the same user, make sure the denial - # is handled properly - SoftwareSecurePhotoVerification.objects.create( - user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' - ) - status = IDVerificationService.user_status(user) - expected_status = { - 'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True, 'verification_expiry': '' - } - self.assertDictEqual(status, expected_status) + with freeze_time('2015-02-02'): + # create another photo verification for the same user, make sure the denial + # is handled properly + SoftwareSecurePhotoVerification.objects.create( + user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' + ) + status = IDVerificationService.user_status(user) + expected_status = { + 'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True, 'verification_expiry': '', + 'status_date': '', + } + self.assertDictEqual(status, expected_status) - # test for when sso verification has been created - SSOVerification.objects.create(user=user, status='approved') - status = IDVerificationService.user_status(user) - expected_status = {'status': 'approved', 'error': '', 'should_display': False, 'verification_expiry': ''} - self.assertDictEqual(status, expected_status) + with freeze_time('2015-03-02'): + # test for when sso verification has been created + SSOVerification.objects.create(user=user, status='approved') + status = IDVerificationService.user_status(user) + expected_status = {'status': 'approved', 'error': '', 'should_display': False, 'verification_expiry': '', + 'status_date': datetime.now(utc)} + self.assertDictEqual(status, expected_status) - # create another sso verification for the same user, make sure the denial - # is handled properly - SSOVerification.objects.create(user=user, status='denied') - status = IDVerificationService.user_status(user) - expected_status = {'status': 'must_reverify', 'error': '', 'should_display': False, 'verification_expiry': ''} - self.assertDictEqual(status, expected_status) + with freeze_time('2015-04-02'): + # create another sso verification for the same user, make sure the denial + # is handled properly + SSOVerification.objects.create(user=user, status='denied') + status = IDVerificationService.user_status(user) + expected_status = {'status': 'must_reverify', 'error': '', 'should_display': False, 'verification_expiry': '', + 'status_date': ''} + self.assertDictEqual(status, expected_status) - # test for when manual verification has been created - ManualVerification.objects.create(user=user, status='approved') - status = IDVerificationService.user_status(user) - expected_status = {'status': 'approved', 'error': '', 'should_display': False, 'verification_expiry': ''} - self.assertDictEqual(status, expected_status) + with freeze_time('2015-05-02'): + # test for when manual verification has been created + ManualVerification.objects.create(user=user, status='approved') + status = IDVerificationService.user_status(user) + expected_status = {'status': 'approved', 'error': '', 'should_display': False, 'verification_expiry': '', + 'status_date': datetime.now(utc)} + self.assertDictEqual(status, expected_status) @ddt.unpack @ddt.data( @@ -125,7 +143,6 @@ class TestIDVerificationService(ModuleStoreTestCase): with patch( 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified' ) as mock_verification: - mock_verification.return_value = status status = IDVerificationService.verification_status_for_user(user, enrollment_mode)