diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index b0bf62afa9..0fa7b0f33d 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -5,7 +5,7 @@ import pytz from datetime import datetime from django.db import models -from collections import namedtuple +from collections import namedtuple, defaultdict from django.utils.translation import ugettext_lazy as _ from django.db.models import Q @@ -66,6 +66,70 @@ class CourseMode(models.Model): """ meta attributes of this model """ unique_together = ('course_id', 'mode_slug', 'currency') + @classmethod + def all_modes_for_courses(cls, course_id_list): + """Find all modes for a list of course IDs, including expired modes. + + Courses that do not have a course mode will be given a default mode. + + Arguments: + course_id_list (list): List of `CourseKey`s + + Returns: + dict mapping `CourseKey` to lists of `Mode` + + """ + modes_by_course = defaultdict(list) + for mode in cls.objects.filter(course_id__in=course_id_list): + modes_by_course[mode.course_id].append( + Mode( + mode.mode_slug, + mode.mode_display_name, + mode.min_price, + mode.suggested_prices, + mode.currency, + mode.expiration_datetime, + mode.description + ) + ) + + # Assign default modes if nothing available in the database + missing_courses = set(course_id_list) - set(modes_by_course.keys()) + for course_id in missing_courses: + modes_by_course[course_id] = [cls.DEFAULT_MODE] + + return modes_by_course + + @classmethod + def all_and_unexpired_modes_for_courses(cls, course_id_list): + """Retrieve course modes for a list of courses. + + To reduce the number of database queries, this function + loads *all* course modes, then creates a second list + of unexpired course modes. + + Arguments: + course_id_list (list of `CourseKey`): List of courses for which + to retrieve course modes. + + Returns: + Tuple of `(all_course_modes, unexpired_course_modes)`, where + the first is a list of *all* `Mode`s (including expired ones), + and the second is a list of only unexpired `Mode`s. + + """ + now = datetime.now(pytz.UTC) + all_modes = cls.all_modes_for_courses(course_id_list) + unexpired_modes = { + course_id: [ + mode for mode in modes + if mode.expiration_datetime is None or mode.expiration_datetime >= now + ] + for course_id, modes in all_modes.iteritems() + } + + return (all_modes, unexpired_modes) + @classmethod def modes_for_course(cls, course_id): """ @@ -91,23 +155,48 @@ class CourseMode(models.Model): return modes @classmethod - def modes_for_course_dict(cls, course_id): + def modes_for_course_dict(cls, course_id, modes=None): + """Returns the non-expired modes for a particular course. + + Arguments: + course_id (CourseKey): Search for course modes for this course. + + Keyword Arguments: + modes (list of `Mode`): If provided, search through this list + of course modes. This can be used to avoid an additional + database query if you have already loaded the modes list. + + Returns: + dict: Keys are mode slugs, values are lists of `Mode` namedtuples. + """ - Returns the non-expired modes for a particular course as a - dictionary with the mode slug as the key - """ - return {mode.slug: mode for mode in cls.modes_for_course(course_id)} + if modes is None: + modes = cls.modes_for_course(course_id) + return {mode.slug: mode for mode in modes} @classmethod - def mode_for_course(cls, course_id, mode_slug): - """ - Returns the mode for the course corresponding to mode_slug. + def mode_for_course(cls, course_id, mode_slug, modes=None): + """Returns the mode for the course corresponding to mode_slug. Returns only non-expired modes. If this particular mode is not set for the course, returns None + + Arguments: + course_id (CourseKey): Search for course modes for this course. + mode_slug (str): Search for modes with this slug. + + Keyword Arguments: + modes (list of `Mode`): If provided, search through this list + of course modes. This can be used to avoid an additional + database query if you have already loaded the modes list. + + Returns: + Mode + """ - modes = cls.modes_for_course(course_id) + if modes is None: + modes = cls.modes_for_course(course_id) matched = [m for m in modes if m.slug == mode_slug] if matched: @@ -116,15 +205,28 @@ class CourseMode(models.Model): return None @classmethod - def verified_mode_for_course(cls, course_id): - """ - Since we have two separate modes that can go through the verify flow, + def verified_mode_for_course(cls, course_id, modes=None): + """Find a verified mode for a particular course. + + Since we have multiple modes that can go through the verify flow, we want to be able to select the 'correct' verified mode for a given course. Currently, we prefer to return the professional mode over the verified one if both exist for the given course. + + Arguments: + course_id (CourseKey): Search for course modes for this course. + + Keyword Arguments: + modes (list of `Mode`): If provided, search through this list + of course modes. This can be used to avoid an additional + database query if you have already loaded the modes list. + + Returns: + Mode or None + """ - modes_dict = cls.modes_for_course_dict(course_id) + modes_dict = cls.modes_for_course_dict(course_id, modes=modes) verified_mode = modes_dict.get('verified', None) professional_mode = modes_dict.get('professional', None) # we prefer professional over verify diff --git a/common/djangoapps/course_modes/tests/test_models.py b/common/djangoapps/course_modes/tests/test_models.py index 85a87b3a72..8a2aa3f065 100644 --- a/common/djangoapps/course_modes/tests/test_models.py +++ b/common/djangoapps/course_modes/tests/test_models.py @@ -10,6 +10,7 @@ import pytz import ddt from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.locator import CourseLocator from django.test import TestCase from course_modes.models import CourseMode, Mode @@ -163,3 +164,45 @@ class CourseModeModelTest(TestCase): # Verify that we can or cannot auto enroll self.assertEqual(CourseMode.can_auto_enroll(self.course_key), can_auto_enroll) + + def test_all_modes_for_courses(self): + now = datetime.now(pytz.UTC) + future = now + timedelta(days=1) + past = now - timedelta(days=1) + + # Unexpired, no expiration date + CourseMode.objects.create( + course_id=self.course_key, + mode_display_name="Honor No Expiration", + mode_slug="honor_no_expiration", + expiration_datetime=None + ) + + # Unexpired, expiration date in future + CourseMode.objects.create( + course_id=self.course_key, + mode_display_name="Honor Not Expired", + mode_slug="honor_not_expired", + expiration_datetime=future + ) + + # Expired + CourseMode.objects.create( + course_id=self.course_key, + mode_display_name="Verified Expired", + mode_slug="verified_expired", + expiration_datetime=past + ) + + # We should get all of these back when querying for *all* course modes, + # including ones that have expired. + other_course_key = CourseLocator(org="not", course="a", run="course") + all_modes = CourseMode.all_modes_for_courses([self.course_key, other_course_key]) + self.assertEqual(len(all_modes[self.course_key]), 3) + self.assertEqual(all_modes[self.course_key][0].name, "Honor No Expiration") + self.assertEqual(all_modes[self.course_key][1].name, "Honor Not Expired") + self.assertEqual(all_modes[self.course_key][2].name, "Verified Expired") + + # Check that we get a default mode for when no course mode is available + self.assertEqual(len(all_modes[other_course_key]), 1) + self.assertEqual(all_modes[other_course_key][0], CourseMode.DEFAULT_MODE) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 23bba3d0dd..b29ff084a6 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -1,5 +1,7 @@ """Helpers for the student app. """ import time +from datetime import datetime +from pytz import UTC from django.utils.http import cookie_date from django.conf import settings from django.core.urlresolvers import reverse @@ -9,6 +11,7 @@ from third_party_auth import ( # pylint: disable=W0611 pipeline, provider, is_enabled as third_party_auth_enabled ) +from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401 def auth_pipeline_urls(auth_entry, redirect_url=None, course_id=None): @@ -111,3 +114,118 @@ def set_logged_in_cookie(request, response): def is_logged_in_cookie_set(request): """Check whether the request has the logged in cookie set. """ return settings.EDXMKTG_COOKIE_NAME in request.COOKIES + + +# Enumeration of per-course verification statuses +# we display on the student dashboard. +VERIFY_STATUS_NEED_TO_VERIFY = "verify_need_to_verify" +VERIFY_STATUS_SUBMITTED = "verify_submitted" +VERIFY_STATUS_APPROVED = "verify_approved" +VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline" + + +def check_verify_status_by_course(user, course_enrollment_pairs, all_course_modes): + """Determine the per-course verification statuses for a given user. + + The possible statuses are: + * 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_APPROVED: The student has been successfully verified. + * VERIFY_STATUS_MISSED_DEADLINE: The student did not submit photos within the course's deadline. + + It is is also possible that a course does NOT have a verification status if: + * The user is not enrolled in a verified mode, meaning that the user didn't pay. + * The course does not offer a verified mode. + * The user submitted photos but an error occurred while verifying them. + * The user submitted photos but the verification was denied. + + In the last two cases, we rely on messages in the sidebar rather than displaying + messages for each course. + + Arguments: + user (User): The currently logged-in user. + course_enrollment_pairs (list): The courses the user is enrolled in. + The list should contain tuples of `(Course, CourseEnrollment)`. + all_course_modes (list): List of all course modes for the student's enrolled courses, + including modes that have expired. + + Returns: + dict: Mapping of course keys verification status dictionaries. + If no verification status is applicable to a course, it will not + be included in the dictionary. + The dictionaries have these keys: + * status (str): One of the enumerated status codes. + * days_until_deadline (int): Number of days until the verification deadline. + * verification_good_until (str): Date string for the verification expiration date. + """ + + status_by_course = {} + + # Retrieve all verifications for the user, sorted in descending + # order by submission datetime + verifications = SoftwareSecurePhotoVerification.objects.filter(user=user) + + for course, enrollment in course_enrollment_pairs: + + # Get the verified mode (if any) for this course + # We pass in the course modes we have already loaded to avoid + # another database hit, as well as to ensure that expired + # course modes are included in the search. + verified_mode = CourseMode.verified_mode_for_course( + course.id, + modes=all_course_modes[course.id] + ) + + # If no verified mode has ever been offered, or the user hasn't enrolled + # as verified, then the course won't display state related to its + # verification status. + if verified_mode is not None and enrollment.mode in CourseMode.VERIFIED_MODES: + deadline = verified_mode.expiration_datetime + relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications) + + # By default, don't show any status related to verification + status = None + + # 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 + elif relevant_verification.status == "submitted": + 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. + # If they submitted but something went wrong (error or denied), + # then don't show any messaging next to the course, since we already + # show messages related to this on the left sidebar. + submitted = ( + relevant_verification is not None and + relevant_verification.status not in ["created", "ready"] + ) + if status is None and not submitted: + if deadline is None or deadline > datetime.now(UTC): + status = VERIFY_STATUS_NEED_TO_VERIFY + else: + status = VERIFY_STATUS_MISSED_DEADLINE + + # Set the status for the course only if we're displaying some kind of message + # Otherwise, leave the course out of the dictionary. + if status is not None: + days_until_deadline = None + verification_good_until = None + + now = datetime.now(UTC) + if deadline is not None and deadline > now: + days_until_deadline = (deadline - now).days + + if relevant_verification is not None: + verification_good_until = relevant_verification.expiration_datetime.strftime("%m/%d/%Y") + + status_by_course[course.id] = { + 'status': status, + 'days_until_deadline': days_until_deadline, + 'verification_good_until': verification_good_until + } + + return status_by_course diff --git a/common/djangoapps/student/tests/test_verification_status.py b/common/djangoapps/student/tests/test_verification_status.py new file mode 100644 index 0000000000..1e5b3c0fe7 --- /dev/null +++ b/common/djangoapps/student/tests/test_verification_status.py @@ -0,0 +1,249 @@ +"""Tests for per-course verification status on the dashboard. """ +from datetime import datetime, timedelta + +import unittest +import ddt +from mock import patch +from pytz import UTC +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.conf import settings + +from student.helpers import ( + VERIFY_STATUS_NEED_TO_VERIFY, + VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_APPROVED, + VERIFY_STATUS_MISSED_DEADLINE +) + +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config +from student.tests.factories import UserFactory, CourseEnrollmentFactory +from course_modes.tests.factories import CourseModeFactory +from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401 + + +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@patch.dict(settings.FEATURES, { + 'SEPARATE_VERIFICATION_FROM_PAYMENT': True, + 'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True +}) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@ddt.ddt +class TestCourseVerificationStatus(ModuleStoreTestCase): + """Tests for per-course verification status on the dashboard. """ + + PAST = datetime.now(UTC) - timedelta(days=5) + FUTURE = datetime.now(UTC) + timedelta(days=5) + + def setUp(self): + self.user = UserFactory(password="edx") + self.course = CourseFactory.create() + success = self.client.login(username=self.user.username, password="edx") + self.assertTrue(success, msg="Did not log in successfully") + + def test_enrolled_as_non_verified(self): + self._setup_mode_and_enrollment(None, "honor") + + # 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" + ) + + # The default course has no verified mode, + # so no verification status should be displayed + self._assert_course_verification_status(None) + + 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.FUTURE, "verified") + response = self.client.get(reverse('dashboard')) + 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(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(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(reverse('dashboard')) + self.assertContains(response, attempt.expiration_datetime.strftime("%m/%d/%Y")) + + def test_missed_verification_deadline(self): + # Expiration date in the past + self._setup_mode_and_enrollment(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.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.created_at = 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.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.created_at = 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.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.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) + + 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( + course_id=self.course.id, + mode_slug="verified", + expiration_datetime=deadline + ) + CourseEnrollmentFactory( + course_id=self.course.id, + user=self.user, + mode=enrollment_mode + ) + + BANNER_ALT_MESSAGES = { + None: "Honor", + VERIFY_STATUS_NEED_TO_VERIFY: "ID Verified Pending Ribbon/Badge", + VERIFY_STATUS_SUBMITTED: "ID Verified Pending Ribbon/Badge", + VERIFY_STATUS_APPROVED: "ID Verified Ribbon/Badge", + VERIFY_STATUS_MISSED_DEADLINE: "Honor" + } + + NOTIFICATION_MESSAGES = { + VERIFY_STATUS_NEED_TO_VERIFY: "You still need to verify for this course.", + VERIFY_STATUS_SUBMITTED: "Thanks for your patience as we process your request.", + VERIFY_STATUS_APPROVED: "You have already verified your ID!", + } + + 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(reverse('dashboard')) + + # Sanity check: verify that the course is on the page + self.assertContains(response, unicode(self.course.id)) + + # Verify that the correct banner is rendered on the dashboard + self.assertContains(response, self.BANNER_ALT_MESSAGES[status]) + + # Verify that the correct copy is rendered on the dashboard + if status is not None: + if status in self.NOTIFICATION_MESSAGES: + self.assertContains(response, self.NOTIFICATION_MESSAGES[status]) + else: + for msg in self.NOTIFICATION_MESSAGES.values(): + self.assertNotContains(response, msg) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9429502cae..8992ad88ba 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -99,7 +99,10 @@ from util.password_policy_validators import ( import third_party_auth from third_party_auth import pipeline, provider -from student.helpers import auth_pipeline_urls, set_logged_in_cookie +from student.helpers import ( + auth_pipeline_urls, set_logged_in_cookie, + check_verify_status_by_course +) from xmodule.error_module import ErrorDescriptor from shoppingcart.models import CourseRegistrationCode @@ -495,9 +498,14 @@ def dashboard(request): course_enrollment_pairs.sort(key=lambda x: x[1].created, reverse=True) # Retrieve the course modes for each course + enrolled_course_ids = [course.id for course, __ in course_enrollment_pairs] + all_course_modes, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) course_modes_by_course = { - course.id: CourseMode.modes_for_course_dict(course.id) - for course, __ in course_enrollment_pairs + course_id: { + mode.slug: mode + for mode in modes + } + for course_id, modes in unexpired_course_modes.iteritems() } # Check to see if the student has recently enrolled in a course. @@ -537,6 +545,29 @@ def dashboard(request): for course, enrollment in course_enrollment_pairs } + # Determine the per-course verification status + # This is a dictionary in which the keys are course locators + # and the values are one of: + # + # VERIFY_STATUS_NEED_TO_VERIFY + # VERIFY_STATUS_SUBMITTED + # VERIFY_STATUS_APPROVED + # VERIFY_STATUS_MISSED_DEADLINE + # + # Each of which correspond to a particular message to display + # next to the course on the dashboard. + # + # If a course is not included in this dictionary, + # there is no verification messaging to display. + if settings.FEATURES.get("SEPARATE_VERIFICATION_FROM_PAYMENT"): + verify_status_by_course = check_verify_status_by_course( + user, + course_enrollment_pairs, + all_course_modes + ) + else: + verify_status_by_course = {} + cert_statuses = { course.id: cert_info(request.user, course) for course, _enrollment in course_enrollment_pairs @@ -615,6 +646,7 @@ def dashboard(request): 'show_email_settings_for': show_email_settings_for, 'reverifications': reverifications, 'verification_status': verification_status, + 'verification_status_by_course': verify_status_by_course, 'verification_msg': verification_msg, 'show_refund_option_for': show_refund_option_for, 'block_courses': block_courses, diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index f7c2a2b17d..9d4296a4a7 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -188,11 +188,8 @@ class PhotoVerification(StatusModel): Returns the earliest allowed date given the settings """ - DAYS_GOOD_FOR = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] - allowed_date = ( - datetime.now(pytz.UTC) - timedelta(days=DAYS_GOOD_FOR) - ) - return allowed_date + days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + return datetime.now(pytz.UTC) - timedelta(days=days_good_for) @classmethod def user_is_verified(cls, user, earliest_allowed_date=None, window=None): @@ -310,6 +307,66 @@ class PhotoVerification(StatusModel): return (status, error_msg) + @classmethod + def verification_for_datetime(cls, deadline, candidates): + """Find a verification in a set that applied during a particular datetime. + + A verification is considered "active" during a datetime if: + 1) The verification was created before the datetime, and + 2) The verification is set to expire after the datetime. + + Note that verification status is *not* considered here, + just the start/expire dates. + + If multiple verifications were active at the deadline, + returns the most recently created one. + + Arguments: + deadline (datetime): The datetime at which the verification applied. + If `None`, then return the most recently created candidate. + candidates (list of `PhotoVerification`s): Potential verifications to search through. + + Returns: + PhotoVerification: A photo verification that was active at the deadline. + If no verification was active, return None. + + """ + if len(candidates) == 0: + return None + + # If there's no deadline, then return the most recently created verification + if deadline is None: + return candidates[0] + + # Otherwise, look for a verification that was in effect at the deadline, + # preferring recent verifications. + # If no such verification is found, implicitly return `None` + for verification in candidates: + if verification.active_at_datetime(deadline): + return verification + + @property + def expiration_datetime(self): + """Datetime that the verification will expire. """ + days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] + return self.created_at + timedelta(days=days_good_for) + + def active_at_datetime(self, deadline): + """Check whether the verification was active at a particular datetime. + + Arguments: + deadline (datetime): The date at which the verification was active + (created before and expired after). + + Returns: + bool + + """ + return ( + self.created_at < deadline and + self.expiration_datetime > deadline + ) + def parsed_error_msg(self): """ Sometimes, the error message we've received needs to be parsed into diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 7717508126..9ec43f915e 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -417,6 +417,83 @@ class TestPhotoVerification(TestCase): parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") + def test_active_at_datetime(self): + user = UserFactory.create() + attempt = SoftwareSecurePhotoVerification.objects.create(user=user) + + # Not active before the created date + before = attempt.created_at - timedelta(seconds=1) + self.assertFalse(attempt.active_at_datetime(before)) + + # Active immediately after created date + after_created = attempt.created_at + timedelta(seconds=1) + self.assertTrue(attempt.active_at_datetime(after_created)) + + # Active immediately before expiration date + expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + before_expiration = expiration - timedelta(seconds=1) + 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)) + + def test_verification_for_datetime(self): + user = UserFactory.create() + now = datetime.now(pytz.UTC) + + # No attempts in the query set, so should return None + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(now, query) + self.assertIs(result, None) + + # Should also return None if no deadline specified + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) + self.assertIs(result, None) + + # Make an attempt + attempt = SoftwareSecurePhotoVerification.objects.create(user=user) + + # Before the created date, should get no results + before = attempt.created_at - timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(before, query) + self.assertIs(result, None) + + # Immediately after the created date, should get the attempt + after_created = attempt.created_at + timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(after_created, query) + self.assertEqual(result, attempt) + + # If no deadline specified, should return first available + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) + self.assertEqual(result, attempt) + + # Immediately before the expiration date, should get the attempt + expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + before_expiration = expiration - timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(before_expiration, query) + self.assertEqual(result, attempt) + + # Immediately after the expiration date, should not get the attempt + after = expiration + timedelta(seconds=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(after, query) + self.assertIs(result, None) + + # Create a second attempt in the same window + second_attempt = SoftwareSecurePhotoVerification.objects.create(user=user) + + # Now we should get the newer attempt + deadline = second_attempt.created_at + timedelta(days=1) + query = SoftwareSecurePhotoVerification.objects.filter(user=user) + result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query) + self.assertEqual(result, second_attempt) + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7d42ddbdf0..62e5c22265 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -293,6 +293,9 @@ FEATURES = { # Enable display of enrollment counts in instructor and legacy analytics dashboard 'DISPLAY_ANALYTICS_ENROLLMENTS': True, + + # Separate the verification flow from the payment flow + 'SEPARATE_VERIFICATION_FROM_PAYMENT': False, } # Ignore static asset files on import which match this pattern diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index dbea4d34f2..75bfc006bc 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -180,7 +180,8 @@ <% show_refund_option = (course.id in show_refund_option_for) %> <% is_paid_course = (course.id in enrolled_courses_either_paid) %> <% is_course_blocked = (course.id in block_courses) %> - <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked" /> + <% course_verification_status = verification_status_by_course.get(course.id, {}) %> + <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status" /> % endfor diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 21c6ae879c..ab950fc06a 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,9 +1,15 @@ -<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked" /> +<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status" /> <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse from courseware.courses import course_image_url, get_course_about_section + from student.helpers import ( + VERIFY_STATUS_NEED_TO_VERIFY, + VERIFY_STATUS_SUBMITTED, + VERIFY_STATUS_APPROVED, + VERIFY_STATUS_MISSED_DEADLINE + ) %> <% @@ -45,28 +51,49 @@ % endif % if settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES'): - % if enrollment.mode == "verified": + % if enrollment.mode == "verified": + % if settings.FEATURES.get('SEPARATE_VERIFICATION_FROM_PAYMENT'): + % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED]: + + ${_("Enrolled as: ")} + ID Verified Pending Ribbon/Badge + ${_("Verified Pending")} + + % elif verification_status.get('status') == VERIFY_STATUS_APPROVED: + + ${_("Enrolled as: ")} + ID Verified Ribbon/Badge + ${_("Verified")} + + % else: + + ${_("Enrolled as: ")} + ${_("Honor Code")} + + % endif + % else: ${_("Enrolled as: ")} ID Verified Ribbon/Badge ${_("Verified")} - % elif enrollment.mode == "honor": - - ${_("Enrolled as: ")} - ${_("Honor Code")} - - % elif enrollment.mode == "audit": - - ${_("Enrolled as: ")} - ${_("Auditing")} - - % elif enrollment.mode == "professional": - - ${_("Enrolled as: ")} - ${_("Professional Ed")} - % endif + % elif enrollment.mode == "honor": + + ${_("Enrolled as: ")} + ${_("Honor Code")} + + % elif enrollment.mode == "audit": + + ${_("Enrolled as: ")} + ${_("Auditing")} + + % elif enrollment.mode == "professional": + + ${_("Enrolled as: ")} + ${_("Professional Ed")} + + % endif % endif
@@ -100,6 +127,32 @@ <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/> % endif + % if settings.FEATURES.get('SEPARATE_VERIFICATION_FROM_PAYMENT'): + % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED] and not is_course_blocked: +
+ % if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY: + % if verification_status['days_until_deadline'] is not None: +

${_('Verification not yet complete.')}

+

${_('You only have {days} days left to verify for this course.').format(days=verification_status['days_until_deadline'])}

+ % else: +

${_('Almost there!')}

+

${_('You still need to verify for this course.')}

+ % endif + ## TODO: style this button +

${_('Verify Now')}

+ % elif verification_status['status'] == VERIFY_STATUS_SUBMITTED: +

${_('You have already verified your ID!')}

+

${_('Thanks for your patience as we process your request.')}

+ % elif verification_status['status'] == VERIFY_STATUS_APPROVED: +

${_('You have already verified your ID!')}

+ % if verification_status['verification_good_until'] is not None: +

${_('Your verification status is good until {date}.').format(date=verification_status['verification_good_until'])} + % endif + % endif +

+ % endif + % endif + % if course_mode_info['show_upsell'] and not is_course_blocked: