Show student verification status on the dashboard.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
249
common/djangoapps/student/tests/test_verification_status.py
Normal file
249
common/djangoapps/student/tests/test_verification_status.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
</ul>
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
% 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]:
|
||||
<span class="sts-enrollment" title="${_("Your verification is pending")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<img class="deco-graphic" src="${static.url('images/vcert-ribbon-s.png')}" alt="ID Verified Pending Ribbon/Badge" />
|
||||
<span class="sts-enrollment-value">${_("Verified Pending")}</span>
|
||||
</span>
|
||||
% elif verification_status.get('status') == VERIFY_STATUS_APPROVED:
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as a verified student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<img class="deco-graphic" src="${static.url('images/vcert-ribbon-s.png')}" alt="ID Verified Ribbon/Badge" />
|
||||
<span class="sts-enrollment-value">${_("Verified")}</span>
|
||||
</span>
|
||||
% else:
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as an honor code student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Honor Code")}</span>
|
||||
</span>
|
||||
% endif
|
||||
% else:
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as a verified student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<img class="deco-graphic" src="${static.url('images/vcert-ribbon-s.png')}" alt="ID Verified Ribbon/Badge" />
|
||||
<span class="sts-enrollment-value">${_("Verified")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "honor":
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as an honor code student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Honor Code")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "audit":
|
||||
<span class="sts-enrollment" title="${_("You're auditing this course")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Auditing")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "professional":
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as a professional education student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Professional Ed")}</span>
|
||||
</span>
|
||||
% endif
|
||||
% elif enrollment.mode == "honor":
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as an honor code student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Honor Code")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "audit":
|
||||
<span class="sts-enrollment" title="${_("You're auditing this course")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Auditing")}</span>
|
||||
</span>
|
||||
% elif enrollment.mode == "professional":
|
||||
<span class="sts-enrollment" title="${_("You're enrolled as a professional education student")}">
|
||||
<span class="label">${_("Enrolled as: ")}</span>
|
||||
<span class="sts-enrollment-value">${_("Professional Ed")}</span>
|
||||
</span>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
<section class="info">
|
||||
@@ -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:
|
||||
<div class="message message-status is-shown">
|
||||
% if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY:
|
||||
% if verification_status['days_until_deadline'] is not None:
|
||||
<h4 class="message-title">${_('Verification not yet complete.')}</h4>
|
||||
<p class="message-copy">${_('You only have {days} days left to verify for this course.').format(days=verification_status['days_until_deadline'])}</p>
|
||||
% else:
|
||||
<h4 class="message-title">${_('Almost there!')}</h4>
|
||||
<p class="message-copy">${_('You still need to verify for this course.')}</p>
|
||||
% endif
|
||||
## TODO: style this button
|
||||
<p>${_('Verify Now')}</p>
|
||||
% 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>
|
||||
% elif verification_status['status'] == VERIFY_STATUS_APPROVED:
|
||||
<h4 class="message-title">${_('You have already verified your ID!')}</h4>
|
||||
% if verification_status['verification_good_until'] is not None:
|
||||
<p class="message-copy">${_('Your verification status is good until {date}.').format(date=verification_status['verification_good_until'])}
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
% if course_mode_info['show_upsell'] and not is_course_blocked:
|
||||
<div class="message message-upsell has-actions is-expandable is-shown">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user