"""Tests for per-course verification status on the dashboard. """ import unittest from datetime import datetime, timedelta from unittest.mock import patch import ddt import six from django.conf import settings from django.test import override_settings from django.urls import reverse from django.utils.timezone import now from pytz import UTC from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.helpers import ( VERIFY_STATUS_APPROVED, VERIFY_STATUS_MISSED_DEADLINE, VERIFY_STATUS_NEED_TO_REVERIFY, VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_SUBMITTED ) from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @ddt.ddt class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): """Tests for per-course verification status on the dashboard. """ PAST = 'past' FUTURE = 'future' DATES = { PAST: datetime.now(UTC) - timedelta(days=5), FUTURE: datetime.now(UTC) + timedelta(days=5), None: None, } URLCONF_MODULES = ['lms.djangoapps.verify_student.urls'] def setUp(self): # Invoke UrlResetMixin super().setUp() self.user = UserFactory(password="edx") self.course = CourseFactory.create() success = self.client.login(username=self.user.username, password="edx") assert success, 'Did not log in successfully' self.dashboard_url = reverse('dashboard') def test_enrolled_as_non_verified(self): self._setup_mode_and_enrollment(None, "audit") # Expect that the course appears on the dashboard # without any verification messaging self._assert_course_verification_status(None) def test_no_verified_mode_available(self): # Enroll the student in a verified mode, but don't # create any verified course mode. # This won't happen unless someone deletes a course mode, # but if so, make sure we handle it gracefully. CourseEnrollmentFactory( course_id=self.course.id, user=self.user, mode="verified" ) # Continue to show the student as needing to verify. # The student is enrolled as verified, so we might as well let them # complete verification. We'd need to change their enrollment mode # anyway to ensure that the student is issued the correct kind of certificate. self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY) def test_need_to_verify_no_expiration(self): self._setup_mode_and_enrollment(None, "verified") # Since the student has not submitted a photo verification, # the student should see a "need to verify" message self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY) # Start the photo verification process, but do not submit # Since we haven't submitted the verification, we should still # see the "need to verify" message attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY) # Upload images, but don't submit to the verification service # We should still need to verify attempt.mark_ready() self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY) def test_need_to_verify_expiration(self): self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified") response = self.client.get(self.dashboard_url) self.assertContains(response, self.BANNER_ALT_MESSAGES[VERIFY_STATUS_NEED_TO_VERIFY]) self.assertContains(response, "You only have 4 days left to verify for this course.") @ddt.data(None, FUTURE) def test_waiting_approval(self, expiration): self._setup_mode_and_enrollment(self.DATES[expiration], "verified") # The student has submitted a photo verification attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() # Now the student should see a "verification submitted" message self._assert_course_verification_status(VERIFY_STATUS_SUBMITTED) @ddt.data(None, FUTURE) def test_fully_verified(self, expiration): self._setup_mode_and_enrollment(self.DATES[expiration], "verified") # The student has an approved verification attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() attempt.approve() # Expect that the successfully verified message is shown self._assert_course_verification_status(VERIFY_STATUS_APPROVED) # Check that the "verification good until" date is displayed response = self.client.get(self.dashboard_url) self.assertContains(response, attempt.expiration_datetime.strftime("%m/%d/%Y")) @patch("lms.djangoapps.verify_student.services.is_verification_expiring_soon") def test_verify_resubmit_button_on_dashboard(self, mock_expiry): mock_expiry.return_value = True SoftwareSecurePhotoVerification.objects.create( user=self.user, status='approved', expiration_date=now() + timedelta(days=1) ) response = self.client.get(self.dashboard_url) self.assertContains(response, "Resubmit Verification") mock_expiry.return_value = False response = self.client.get(self.dashboard_url) self.assertNotContains(response, "Resubmit Verification") def test_missed_verification_deadline(self): # Expiration date in the past self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified") # The student does NOT have an approved verification # so the status should show that the student missed the deadline. self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE) def test_missed_verification_deadline_verification_was_expired(self): # Expiration date in the past self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified") # Create a verification, but the expiration date of the verification # occurred before the deadline. attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() attempt.approve() attempt.expiration_date = self.DATES[self.PAST] - timedelta(days=900) attempt.save() # The student didn't have an approved verification at the deadline, # so we should show that the student missed the deadline. self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE) def test_missed_verification_deadline_but_later_verified(self): # Expiration date in the past self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified") # Successfully verify, but after the deadline has already passed attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() attempt.approve() attempt.expiration_date = self.DATES[self.PAST] - timedelta(days=900) attempt.save() # The student didn't have an approved verification at the deadline, # so we should show that the student missed the deadline. self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE) def test_verification_denied(self): # Expiration date in the future self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified") # Create a verification with the specified status attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() attempt.deny("Not valid!") # Since this is not a status we handle, don't display any # messaging relating to verification self._assert_course_verification_status(None) def test_verification_error(self): # Expiration date in the future self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified") # Create a verification with the specified status attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.status = "must_retry" attempt.system_error("Error!") # Since this is not a status we handle, don't display any # 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.DATES[self.FUTURE], "verified") # Create a verification attempt that: # 1) Is current (submitted in the last year) # 2) Will expire by the deadline for the course 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) @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.DATES[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() # 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 their # 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.DATES[self.PAST], "verified") # The deadline has passed, and we've asked the student # to reverify (through the support team). attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() # Expect that the user's displayed enrollment mode is verified. self._assert_course_verification_status(VERIFY_STATUS_APPROVED) def test_with_two_verifications(self): # checking if a user has two verification and but most recent verification course deadline is expired self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified") # The student has an approved verification attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt.mark_ready() attempt.submit() attempt.approve() # Making created at to previous date to differentiate with 2nd attempt. attempt.created_at = datetime.now(UTC) - timedelta(days=1) attempt.save() # Expect that the successfully verified message is shown self._assert_course_verification_status(VERIFY_STATUS_APPROVED) # Check that the "verification good until" date is displayed response = self.client.get(self.dashboard_url) self.assertContains(response, attempt.expiration_datetime.strftime("%m/%d/%Y")) # Adding another verification with different course. # Its created_at is greater than course deadline. course2 = CourseFactory.create() CourseModeFactory.create( course_id=course2.id, mode_slug="verified", expiration_datetime=self.DATES[self.PAST] ) CourseEnrollmentFactory( course_id=course2.id, user=self.user, mode="verified" ) # The student has an approved verification attempt2 = SoftwareSecurePhotoVerification.objects.create(user=self.user) attempt2.mark_ready() attempt2.submit() attempt2.approve() attempt2.save() # Mark the attemp2 as approved so its date will appear on dasboard. self._assert_course_verification_status(VERIFY_STATUS_APPROVED) response2 = self.client.get(self.dashboard_url) self.assertContains(response2, attempt2.expiration_datetime.strftime("%m/%d/%Y")) self.assertContains(response2, attempt2.expiration_datetime.strftime("%m/%d/%Y"), count=2) def _setup_mode_and_enrollment(self, deadline, enrollment_mode): """Create a course mode and enrollment. Arguments: deadline (datetime): The deadline for submitting your verification. enrollment_mode (str): The mode of the enrollment. """ CourseModeFactory.create( course_id=self.course.id, mode_slug="verified", expiration_datetime=deadline ) CourseEnrollmentFactory( course_id=self.course.id, user=self.user, mode=enrollment_mode ) VerificationDeadline.set_deadline(self.course.id, deadline) BANNER_ALT_MESSAGES = { VERIFY_STATUS_NEED_TO_VERIFY: "ID verification pending", VERIFY_STATUS_SUBMITTED: "ID verification pending", VERIFY_STATUS_APPROVED: "ID Verified Ribbon/Badge", } NOTIFICATION_MESSAGES = { VERIFY_STATUS_NEED_TO_VERIFY: [ "You still need to verify for this course.", "Verification not yet complete" ], 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 = { None: "audit", VERIFY_STATUS_NEED_TO_VERIFY: "verified", VERIFY_STATUS_SUBMITTED: "verified", VERIFY_STATUS_APPROVED: "verified", VERIFY_STATUS_MISSED_DEADLINE: "audit", VERIFY_STATUS_NEED_TO_REVERIFY: "audit", VERIFY_STATUS_RESUBMITTED: "audit" } def _assert_course_verification_status(self, status): """Check whether the specified verification status is shown on the dashboard. Arguments: status (str): One of the verification status constants. If None, check that *none* of the statuses are displayed. Raises: AssertionError """ response = self.client.get(self.dashboard_url) # Sanity check: verify that the course is on the page self.assertContains(response, str(self.course.id)) # Verify that the correct banner is rendered on the dashboard alt_text = self.BANNER_ALT_MESSAGES.get(status) if alt_text: self.assertContains(response, alt_text) # Verify that the correct banner color is rendered self.assertContains( response, "