diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index d5d8a9159239e0abb20ff42798e3045b7a73e7d9..39b48ece318e6e456218452498e1cdb23cd815b2 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -2,6 +2,7 @@ from datetime import datetime import urllib +from pytz import UTC from django.core.urlresolvers import reverse, NoReverseMatch from oauth2_provider.models import ( AccessToken as dot_access_token, @@ -11,7 +12,6 @@ from provider.oauth2.models import ( AccessToken as dop_access_token, RefreshToken as dop_refresh_token ) -from pytz import UTC import third_party_auth from lms.djangoapps.verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification @@ -22,6 +22,7 @@ from course_modes.models import CourseMode # we display on the student dashboard. VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify" VERIFY_STATUS_SUBMITTED = "verify_submitted" +VERIFY_STATUS_RESUBMITTED = "re_verify_submitted" VERIFY_STATUS_APPROVED = "verify_approved" VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline" VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify" @@ -40,6 +41,8 @@ def check_verify_status_by_course(user, course_enrollments): * VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification. * VERIFY_STATUS_SUBMITTED: The student has submitted photos for verification, but has have not yet been approved. + * VERIFY_STATUS_RESUBMITTED: The student has re-submitted photos for re-verification while + they still have an active but expiring ID verification * VERIFY_STATUS_APPROVED: The student has been successfully verified. * VERIFY_STATUS_MISSED_DEADLINE: The student did not submit photos within the course's deadline. * VERIFY_STATUS_NEED_TO_REVERIFY: The student has an active verification, but it is @@ -80,6 +83,11 @@ def check_verify_status_by_course(user, course_enrollments): user, queryset=verifications ) + # Retrieve expiration_datetime of most recent approved verification + # To avoid another database hit, we re-use the queryset we have already retrieved. + expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(user, verifications) + verification_expiring_soon = SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime) + # Retrieve verification deadlines for the enrolled courses enrolled_course_keys = [enrollment.course_id for enrollment in course_enrollments] course_deadlines = VerificationDeadline.deadlines_for_courses(enrolled_course_keys) @@ -112,9 +120,15 @@ def check_verify_status_by_course(user, course_enrollments): # Check whether the user was approved or is awaiting approval if relevant_verification is not None: if relevant_verification.status == "approved": - status = VERIFY_STATUS_APPROVED + if verification_expiring_soon: + status = VERIFY_STATUS_NEED_TO_REVERIFY + else: + status = VERIFY_STATUS_APPROVED elif relevant_verification.status == "submitted": - status = VERIFY_STATUS_SUBMITTED + if verification_expiring_soon: + status = VERIFY_STATUS_RESUBMITTED + else: + status = VERIFY_STATUS_SUBMITTED # If the user didn't submit at all, then tell them they need to verify # If the deadline has already passed, then tell them they missed it. @@ -127,11 +141,12 @@ def check_verify_status_by_course(user, course_enrollments): ) if status is None and not submitted: if deadline is None or deadline > datetime.now(UTC): - if has_active_or_pending: - # The user has an active verification, but the verification - # is set to expire before the deadline. Tell the student - # to reverify. - status = VERIFY_STATUS_NEED_TO_REVERIFY + if SoftwareSecurePhotoVerification.user_is_verified(user): + if verification_expiring_soon: + # The user has an active verification, but the verification + # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). + # Tell the student to reverify. + status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_NEED_TO_VERIFY else: diff --git a/common/djangoapps/student/tests/test_verification_status.py b/common/djangoapps/student/tests/test_verification_status.py index 342ad05b073abb5de49975cc273fbf9233f40b2b..a4e023f93a4712e8cf5cbc6b2296743ccfcf0447 100644 --- a/common/djangoapps/student/tests/test_verification_status.py +++ b/common/djangoapps/student/tests/test_verification_status.py @@ -8,10 +8,12 @@ from nose.plugins.attrib import attr from pytz import UTC from django.core.urlresolvers import reverse from django.conf import settings +from django.test import override_settings from student.helpers import ( VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_MISSED_DEADLINE, VERIFY_STATUS_NEED_TO_REVERIFY @@ -192,6 +194,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): # messaging relating to verification self._assert_course_verification_status(None) + @override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10}) def test_verification_will_expire_by_deadline(self): # Expiration date in the future self._setup_mode_and_enrollment(self.FUTURE, "verified") @@ -202,16 +205,36 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() + attempt.approve() + attempt.save() + + # Verify that learner can submit photos if verification is set to expire soon. + self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_REVERIFY) - # This attempt will expire tomorrow, before the course deadline - attempt.created_at = attempt.created_at - timedelta(days=364) + @override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10}) + def test_reverification_submitted_with_current_approved_verificaiton(self): + # Expiration date in the future + self._setup_mode_and_enrollment(self.FUTURE, "verified") + + # Create a verification attempt that is approved but expiring soon + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() attempt.save() - # Expect that the "verify now" message is hidden - # (since the user isn't allowed to submit another attempt while - # a verification is active). + # Verify that learner can submit photos if verification is set to expire soon. self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_REVERIFY) + # Submit photos for reverification + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + + # Expect that learner has submitted photos for reverfication and his/her + # previous verification is set to expired soon. + self._assert_course_verification_status(VERIFY_STATUS_RESUBMITTED) + def test_verification_occurred_after_deadline(self): # Expiration date in the past self._setup_mode_and_enrollment(self.PAST, "verified") @@ -304,9 +327,10 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): "You still need to verify for this course.", "Verification not yet complete" ], - VERIFY_STATUS_SUBMITTED: ["Thanks for your patience as we process your request."], - VERIFY_STATUS_APPROVED: ["You have already verified your ID!"], - VERIFY_STATUS_NEED_TO_REVERIFY: ["Your verification will expire soon!"] + VERIFY_STATUS_SUBMITTED: ["You have submitted your verification information."], + VERIFY_STATUS_RESUBMITTED: ["You have submitted your reverification information."], + VERIFY_STATUS_APPROVED: ["You have successfully verified your ID with edX"], + VERIFY_STATUS_NEED_TO_REVERIFY: ["Your current verification will expire soon."] } MODE_CLASSES = { @@ -315,7 +339,8 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): VERIFY_STATUS_SUBMITTED: "verified", VERIFY_STATUS_APPROVED: "verified", VERIFY_STATUS_MISSED_DEADLINE: "audit", - VERIFY_STATUS_NEED_TO_REVERIFY: "audit" + VERIFY_STATUS_NEED_TO_REVERIFY: "audit", + VERIFY_STATUS_RESUBMITTED: "audit" } def _assert_course_verification_status(self, status): diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 8f8d96313322e6af0d46062b998f31257488f87a..2a1f632fffc095a56eee2f1ced4a3c022be8dd7e 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -257,6 +257,28 @@ class PhotoVerification(StatusModel): ) ).order_by('-created_at') + @classmethod + def get_expiration_datetime(cls, user, queryset=None): + """ + Check whether the user has an approved verification and return the + "expiration_datetime" of most recent "approved" verification. + + Arguments: + user (Object): User + queryset: If a queryset is provided, that will be used instead + of hitting the database. + + Returns: + expiration_datetime: expiration_datetime of most recent "approved" + verification. + """ + if queryset is None: + queryset = cls.objects.filter(user=user) + + photo_verification = queryset.filter(status='approved').first() + if photo_verification: + return photo_verification.expiration_datetime + @classmethod def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, queryset=None): """ @@ -384,7 +406,7 @@ class PhotoVerification(StatusModel): Arguments: deadline (datetime): The date at which the verification was active - (created before and expired after). + (created before and expiration datetime is after today). Returns: bool @@ -392,7 +414,7 @@ class PhotoVerification(StatusModel): """ return ( self.created_at < deadline and - self.expiration_datetime > deadline + self.expiration_datetime > datetime.now(pytz.UTC) ) def parsed_error_msg(self): @@ -944,6 +966,18 @@ class SoftwareSecurePhotoVerification(PhotoVerification): else: return 'ID Verified' + @classmethod + def is_verification_expiring_soon(cls, expiration_datetime): + """ + Returns True if verification is expiring within EXPIRING_SOON_WINDOW. + """ + if expiration_datetime: + if (expiration_datetime - datetime.now(pytz.UTC)).days <= settings.VERIFY_STUDENT.get( + "EXPIRING_SOON_WINDOW"): + return True + + return False + class VerificationDeadline(TimeStampedModel): """ diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 96778e1b45d334b15599ae77dec9848d7eaba078..4f726a90d18b3e3415f204cfa3598315a77f7ef1 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -382,8 +382,9 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): self.assertTrue(attempt.active_at_datetime(before_expiration)) # Not active after the expiration date - after = expiration + timedelta(seconds=1) - self.assertFalse(attempt.active_at_datetime(after)) + attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + attempt.save() + self.assertFalse(attempt.active_at_datetime(datetime.now(pytz.UTC) + timedelta(days=1))) def test_verification_for_datetime(self): user = UserFactory.create() @@ -427,7 +428,9 @@ class TestPhotoVerification(MockS3Mixin, ModuleStoreTestCase): self.assertEqual(result, attempt) # Immediately after the expiration date, should not get the attempt - after = expiration + timedelta(seconds=1) + attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + attempt.save() + after = datetime.now(pytz.UTC) + timedelta(days=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(after, query) self.assertIs(result, None) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 14a2f9d6c04e35b66fc5f4bca28d51ee9a7e425f..f2a1c7f18f23f1c875ec6015c06e2de55b6fa5fe 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -2071,6 +2071,23 @@ class TestReverifyView(TestCase): # Cannot reverify because the user is already verified. self._assert_cannot_reverify() + @override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10}) + def test_reverify_view_can_reverify_approved_expired_soon(self): + """ + Verify that learner can submit photos if verification is set to expired soon. + Verification will be good for next DAYS_GOOD_FOR (i.e here it is 5 days) days, + and learner can submit photos if verification is set to expire in + EXPIRING_SOON_WINDOW(i.e here it is 10 days) or less days. + """ + + attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) + attempt.mark_ready() + attempt.submit() + attempt.approve() + + # Can re-verify because verification is set to expired soon. + self._assert_can_reverify() + def _get_reverify_page(self): """ Retrieve the reverification page and return the response. diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index f849f4bee7f622cdad87c347b773f054c6aaa626..9c37e8dc83bda6c7bc7ff35e6d5c4cde79d89d49 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1376,12 +1376,22 @@ class ReverifyView(View): """ status, _ = SoftwareSecurePhotoVerification.user_status(request.user) + expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(request.user) + can_reverify = False + if expiration_datetime: + if SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime): + # The user has an active verification, but the verification + # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). + # In this case user can resubmit photos for reverification. + can_reverify = True + # If the user has no initial verification or if the verification # process is still ongoing 'pending' or expired then allow the user to # submit the photo verification. # A photo verification is marked as 'pending' if its status is either # 'submitted' or 'must_retry'. - if status in ["none", "must_reverify", "expired", "pending"]: + + if status in ["none", "must_reverify", "expired", "pending"] or can_reverify: context = { "user_full_name": request.user.profile.name, "platform_name": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), diff --git a/lms/envs/common.py b/lms/envs/common.py index 73c2d81795145d0f5346d6e82d511db0f9a2e6cb..b8cd837e9baa6b7d7e1ff9a8e4188ef287ea28ed 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2288,6 +2288,8 @@ MOBILE_STORE_URLS = { ################# Student Verification ################# VERIFY_STUDENT = { "DAYS_GOOD_FOR": 365, # How many days is a verficiation good for? + # The variable represents the window within which a verification is considered to be "expiring soon." + "EXPIRING_SOON_WINDOW": 28, } ### This enables the Metrics tab for the Instructor dashboard ########### diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 651564f1c8daa413c6a407ac46e28f8e1653d440..14774c0e6a344906438fb26cb22766e40d87d58f 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -14,6 +14,7 @@ from openedx.core.lib.time_zone_utils import get_user_time_zone from student.helpers import ( VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_MISSED_DEADLINE, VERIFY_STATUS_NEED_TO_REVERIFY, @@ -299,7 +300,7 @@ from student.helpers import ( <%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/> % endif - % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked: + % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked: <div class="message message-status wrapper-message-primary is-shown"> % if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY: <div class="verification-reminder"> @@ -319,22 +320,24 @@ from student.helpers import ( <a href="${reverse('verify_student_verify_now', kwargs={'course_id': unicode(course_overview.id)})}" class="btn" data-course-id="${course_overview.id}">${_('Verify Now')}</a> </div> % elif verification_status['status'] == VERIFY_STATUS_SUBMITTED: - <h4 class="message-title">${_('You have already verified your ID!')}</h4> - <p class="message-copy">${_('Thanks for your patience as we process your request.')}</p> + <h4 class="message-title">${_('You have submitted your verification information.')}</h4> + <p class="message-copy">${_('You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}</p> + % elif verification_status['status'] == VERIFY_STATUS_RESUBMITTED: + <h4 class="message-title">${_('Your current verification will expire soon!')}</h4> + <p class="message-copy">${_('You have submitted your reverification information. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}</p> % elif verification_status['status'] == VERIFY_STATUS_APPROVED: - <h4 class="message-title">${_('You have already verified your ID!')}</h4> + <h4 class="message-title">${_('You have successfully verified your ID with edX')}</h4> % if verification_status.get('verification_good_until') is not None: - <p class="message-copy">${_('Your verification status is good until {date}.').format(date=verification_status['verification_good_until'])} + <p class="message-copy">${_('Your current verification is effective until {date}.').format(date=verification_status['verification_good_until'])} % endif % elif verification_status['status'] == VERIFY_STATUS_NEED_TO_REVERIFY: - <h4 class="message-title">${_('Your verification will expire soon!')}</h4> + <h4 class="message-title">${_('Your current verification will expire soon.')}</h4> ## Translators: start_link and end_link will be replaced with HTML tags; ## please do not translate these. - <p class="message-copy">${Text(_('Your current verification will expire before the verification deadline ' - 'for this course. {start_link}Re-verify your identity now{end_link} using a webcam and a ' - 'government-issued ID.')).format( + <p class="message-copy">${Text(_('Your current verification will expire in {days} days. {start_link}Re-verify your identity now{end_link} using a webcam and a government-issued photo ID.')).format( start_link=HTML('<a href="{href}">').format(href=reverse('verify_student_reverify')), - end_link=HTML('</a>') + end_link=HTML('</a>'), + days=settings.VERIFY_STUDENT.get("EXPIRING_SOON_WINDOW") )} </p> % endif diff --git a/lms/templates/dashboard/_dashboard_status_verification.html b/lms/templates/dashboard/_dashboard_status_verification.html index ac57f89543a31c6e8c5447cc324e615cab54cdb0..746ba5ef758757ef3dda6403435f70010762c18c 100644 --- a/lms/templates/dashboard/_dashboard_status_verification.html +++ b/lms/templates/dashboard/_dashboard_status_verification.html @@ -7,22 +7,22 @@ from django.core.urlresolvers import reverse %if verification_status == 'approved': <li class="status status-verification is-accepted"> - <span class="title status-title">${_("Verification Status: Approved")}</span> - <p class="status-note">${_("Your edX Verification is reviewed and approved. Your verification status is good for one year after submission.")}</p> + <span class="title status-title">${_("Current Verification Status: Approved")}</span> + <p class="status-note">${_("Your edX verification has been approved. Your verification is effective for one year after submission.")}</p> </li> %endif %if verification_status == 'pending': <li class="status status-verification is-pending"> - <span class="title status-title">${_("Verification Status: Pending")}</span> - <p class="status-note">${_("Your edX Verification is pending. Your verification photos have been submitted and will be reviewed shortly.")}</p> + <span class="title status-title">${_("Current Verification Status: Pending")}</span> + <p class="status-note">${_("Your edX ID verification is pending. Your verification information has been submitted and will be reviewed shortly.")}</p> </li> %endif %if verification_status in ['must_reverify', 'expired']: <li class="status status-verification is-denied"> - <span class="title status-title">${_("Verification Status: Expired")}</span> - <p class="status-note">${_("Your edX Verification has expired. To receive a verified certificate, you have to submit a new photo of yourself and your government-issued photo ID before the course ends.")}</p> + <span class="title status-title">${_("Current Verification Status: Expired")}</span> + <p class="status-note">${_("Your verification has expired. To receive a verified certificate, you must submit a new photo of yourself and your government-issued photo ID before the verification deadline for your course.")}</p> <div class="btn-reverify"> <a href="${reverse('verify_student_reverify')}" class="action action-reverify">${_("Resubmit Verification")}</a> </div>