Commit 93cc03a5 by Will Daly

Separate verification / payment flow.

parent baaf5fa8
......@@ -18,22 +18,29 @@ from mock import patch, Mock
import pytz
from datetime import timedelta, datetime
import ddt
from django.test.client import Client
from django.test import TestCase
from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from django.core.exceptions import ObjectDoesNotExist
from bs4 import BeautifulSoup
from util.testing import UrlResetMixin
from openedx.core.djangoapps.user_api.api import profile as profile_api
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.tests.factories import UserFactory
from opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
from course_modes.tests.factories import CourseModeFactory
from course_modes.models import CourseMode
from shoppingcart.models import Order, CertificateItem
from verify_student.views import render_to_response
from verify_student.views import render_to_response, PayAndVerifyView
from verify_student.models import SoftwareSecurePhotoVerification
from reverification.tests.factories import MidcourseReverificationWindowFactory
......@@ -71,6 +78,10 @@ class TestCreateOrderView(ModuleStoreTestCase):
"""
Tests for the create_order view of verified course registration process
"""
# Minimum size valid image data
IMAGE_DATA = ','
def setUp(self):
self.user = UserFactory.create(username="rusty", password="test")
self.client.login(username="rusty", password="test")
......@@ -95,79 +106,61 @@ class TestCreateOrderView(ModuleStoreTestCase):
)
def test_invalid_photos_data(self):
"""
Test that the invalid photo data cannot be submitted
"""
create_order_post_data = {
'contribution': 50,
'course_id': self.course_id,
'face_image': '',
'photo_id_image': ''
}
response = self.client.post(reverse('verify_student_create_order'), create_order_post_data)
json_response = json.loads(response.content)
self.assertFalse(json_response.get('success'))
self._create_order(
50,
self.course_id,
face_image='',
photo_id_image='',
expect_success=False
)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_invalid_amount(self):
"""
Test that the user cannot give invalid amount
"""
create_order_post_data = {
'contribution': '1.a',
'course_id': self.course_id,
'face_image': ',',
'photo_id_image': ','
}
response = self.client.post(reverse('verify_student_create_order'), create_order_post_data)
self.assertEquals(response.status_code, 400)
response = self._create_order(
'1.a',
self.course_id,
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA,
expect_status_code=400
)
self.assertIn('Selected price is not valid number.', response.content)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_invalid_mode(self):
"""
Test that the course without verified mode cannot be processed
"""
# Create a course that does not have a verified mode
course_id = 'Fake/999/Test_Course'
CourseFactory.create(org='Fake', number='999', display_name='Test Course')
create_order_post_data = {
'contribution': '50',
'course_id': course_id,
'face_image': ',',
'photo_id_image': ','
}
response = self.client.post(reverse('verify_student_create_order'), create_order_post_data)
self.assertEquals(response.status_code, 400)
response = self._create_order(
'50',
course_id,
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA,
expect_status_code=400
)
self.assertIn('This course doesn\'t support verified certificates', response.content)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_create_order_fail_with_get(self):
"""
Test that create_order will not work if wrong http method used
"""
create_order_post_data = {
'contribution': 50,
'course_id': self.course_id,
'face_image': ',',
'photo_id_image': ','
'face_image': self.IMAGE_DATA,
'photo_id_image': self.IMAGE_DATA,
}
# Use the wrong HTTP method
response = self.client.get(reverse('verify_student_create_order'), create_order_post_data)
self.assertEqual(response.status_code, 405)
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
def test_create_order_success(self):
"""
Test that the order is created successfully when given valid data
"""
create_order_post_data = {
'contribution': 50,
'course_id': self.course_id,
'face_image': ',',
'photo_id_image': ','
}
response = self.client.post(reverse('verify_student_create_order'), create_order_post_data)
response = self._create_order(
50,
self.course_id,
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA
)
json_response = json.loads(response.content)
self.assertTrue(json_response.get('success'))
self.assertIsNotNone(json_response.get('orderNumber'))
# Verify that the order exists and is configured correctly
......@@ -178,6 +171,82 @@ class TestCreateOrderView(ModuleStoreTestCase):
self.assertEqual(item.course_id, self.course.id)
self.assertEqual(item.mode, 'verified')
# Verify that a photo verification attempt was created
# TODO (ECOM-188): Once the A/B test of separating verified/payment
# completes, we can delete this check.
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
self.assertEqual(attempt.status, "ready")
# TODO (ECOM-188): Once the A/B test of separating verified/payment
# completes, we can delete this test.
@patch.dict(settings.FEATURES, {
"SEPARATE_VERIFICATION_FROM_PAYMENT": True,
"AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING": True
})
def test_create_order_skip_photo_submission(self):
self._create_order(50, self.course_id)
# Without the face image and photo id image params,
# don't create the verification attempt.
self.assertFalse(
SoftwareSecurePhotoVerification.objects.filter(user=self.user).exists()
)
# Now submit *with* the params
self._create_order(
50, self.course_id,
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA
)
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
self.assertEqual(attempt.status, "ready")
def _create_order(
self, contribution, course_id,
face_image=None,
photo_id_image=None,
expect_success=True,
expect_status_code=200
):
"""Create a new order.
Arguments:
contribution (int): The contribution amount.
course_id (CourseKey): The course to purchase.
Keyword Arguments:
face_image (string): Base-64 encoded image data
photo_id_image (string): Base-64 encoded image data
expect_success (bool): If True, verify that the response was successful.
expect_status_code (int): The expected HTTP status code
Returns:
HttpResponse
"""
url = reverse('verify_student_create_order')
data = {
'contribution': contribution,
'course_id': course_id
}
if face_image is not None:
data['face_image'] = face_image
if photo_id_image is not None:
data['photo_id_image'] = photo_id_image
response = self.client.post(url, data)
self.assertEqual(response.status_code, expect_status_code)
if expect_status_code == 200:
json_response = json.loads(response.content)
if expect_success:
self.assertTrue(json_response.get('success'))
else:
self.assertFalse(json_response.get('success'))
return response
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class TestVerifyView(ModuleStoreTestCase):
......@@ -751,3 +820,763 @@ class TestCreateOrder(ModuleStoreTestCase):
attempt.mark_ready()
attempt.submit()
attempt.approve()
@ddt.ddt
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
class TestSubmitPhotosForVerification(UrlResetMixin, TestCase):
"""Tests for submitting photos for verification. """
USERNAME = "test_user"
PASSWORD = "test_password"
IMAGE_DATA = "abcd,1234"
FULL_NAME = u"Ḟüḷḷ Ṅäṁë"
@patch.dict(settings.FEATURES, {'SEPARATE_VERIFICATION_FROM_PAYMENT': True})
def setUp(self):
super(TestSubmitPhotosForVerification, self).setUp('verify_student.urls')
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.assertTrue(result, msg="Could not log in")
def test_submit_photos(self):
# Submit the photos
self._submit_photos(
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA
)
# Verify that the attempt is created in the database
attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user)
self.assertEqual(attempt.status, "submitted")
# Verify that the user's name wasn't changed
self._assert_full_name(self.user.profile.name)
def test_submit_photos_and_change_name(self):
# Submit the photos, along with a name change
self._submit_photos(
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA,
full_name=self.FULL_NAME
)
# Check that the user's name was changed in the database
self._assert_full_name(self.FULL_NAME)
@ddt.data('face_image', 'photo_id_image')
def test_invalid_image_data(self, invalid_param):
params = {
'face_image': self.IMAGE_DATA,
'photo_id_image': self.IMAGE_DATA
}
params[invalid_param] = ""
response = self._submit_photos(expected_status_code=400, **params)
self.assertEqual(response.content, "Image data is not valid.")
def test_invalid_name(self):
response = self._submit_photos(
face_image=self.IMAGE_DATA,
photo_id_image=self.IMAGE_DATA,
full_name="a",
expected_status_code=400
)
self.assertEqual(response.content, "Name must be at least 2 characters long.")
@ddt.data('face_image', 'photo_id_image')
def test_missing_required_params(self, missing_param):
params = {
'face_image': self.IMAGE_DATA,
'photo_id_image': self.IMAGE_DATA
}
del params[missing_param]
response = self._submit_photos(expected_status_code=400, **params)
self.assertEqual(
response.content,
"Missing required parameters: {missing}".format(missing=missing_param)
)
def _submit_photos(self, face_image=None, photo_id_image=None, full_name=None, expected_status_code=200):
"""Submit photos for verification.
Keyword Arguments:
face_image (str): The base-64 encoded face image data.
photo_id_image (str): The base-64 encoded ID image data.
full_name (unicode): The full name of the user, if the user is changing it.
expected_status_code (int): The expected response status code.
Returns:
HttpResponse
"""
url = reverse("verify_student_submit_photos")
params = {}
if face_image is not None:
params['face_image'] = face_image
if photo_id_image is not None:
params['photo_id_image'] = photo_id_image
if full_name is not None:
params['full_name'] = full_name
response = self.client.post(url, params)
self.assertEqual(response.status_code, expected_status_code)
return response
def _assert_full_name(self, full_name):
"""Check the user's full name.
Arguments:
full_name (unicode): The user's full name.
Raises:
AssertionError
"""
info = profile_api.profile_info(self.user.username)
self.assertEqual(info['full_name'], full_name)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@ddt.ddt
class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
"""Tests for the payment / verification flow views. """
MIN_PRICE = 12
USERNAME = "test_user"
PASSWORD = "test_password"
NOW = datetime.now(pytz.UTC)
YESTERDAY = NOW - timedelta(days=1)
TOMORROW = NOW + timedelta(days=1)
@patch.dict(settings.FEATURES, {'SEPARATE_VERIFICATION_FROM_PAYMENT': True})
def setUp(self):
super(TestPayAndVerifyView, self).setUp('verify_student.urls')
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.assertTrue(result, msg="Could not log in")
@ddt.data("verified", "professional")
def test_start_flow_not_verified(self, course_mode):
course = self._create_course(course_mode)
self._enroll(course.id, "honor")
response = self._get_page('verify_student_start_flow', course.id)
self._assert_displayed_mode(response, course_mode)
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.FIRST_TIME_VERIFY_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_start_flow_skip_intro(self):
course = self._create_course("verified")
response = self._get_page("verify_student_start_flow", course.id, skip_first_step=True)
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.MAKE_PAYMENT_STEP
)
@ddt.data("expired", "denied")
def test_start_flow_expired_or_denied_verification(self, verification_status):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status(verification_status)
response = self._get_page('verify_student_start_flow', course.id)
# Expect the same content as when the user has not verified
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_PAYMENT,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.FIRST_TIME_VERIFY_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
])
@ddt.data(
("verified", "submitted"),
("verified", "approved"),
("verified", "error"),
("professional", "submitted")
)
@ddt.unpack
def test_start_flow_already_verified(self, course_mode, verification_status):
course = self._create_course(course_mode)
self._enroll(course.id, "honor")
self._set_verification_status(verification_status)
response = self._get_page('verify_student_start_flow', course.id)
self._assert_displayed_mode(response, course_mode)
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.FIRST_TIME_VERIFY_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.CREDIT_CARD_REQ,
])
@ddt.data("verified", "professional")
def test_start_flow_already_paid(self, course_mode):
course = self._create_course(course_mode)
self._enroll(course.id, course_mode)
response = self._get_page('verify_student_start_flow', course.id)
self._assert_displayed_mode(response, course_mode)
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_PAYMENT,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.FIRST_TIME_VERIFY_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
])
def test_start_flow_not_enrolled(self):
course = self._create_course("verified")
self._set_verification_status("submitted")
response = self._get_page('verify_student_start_flow', course.id)
# This shouldn't happen if the student has been auto-enrolled,
# but if they somehow end up on this page without enrolling,
# treat them as if they need to pay
response = self._get_page('verify_student_start_flow', course.id)
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.INTRO_STEP
)
self._assert_requirements_displayed(response, [
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_start_flow_unenrolled(self):
course = self._create_course("verified")
self._set_verification_status("submitted")
self._enroll(course.id, "verified")
self._unenroll(course.id)
# If unenrolled, treat them like they haven't paid at all
# (we assume that they've gotten a refund or didn't pay initially)
response = self._get_page('verify_student_start_flow', course.id)
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.INTRO_STEP
)
self._assert_requirements_displayed(response, [
PayAndVerifyView.CREDIT_CARD_REQ,
])
@ddt.data(
("verified", "submitted"),
("verified", "approved"),
("professional", "submitted")
)
@ddt.unpack
def test_start_flow_already_verified_and_paid(self, course_mode, verification_status):
course = self._create_course(course_mode)
self._enroll(course.id, course_mode)
self._set_verification_status(verification_status)
response = self._get_page(
'verify_student_start_flow',
course.id,
expected_status_code=302
)
self._assert_redirects_to_dashboard(response)
def test_verify_now(self):
# We've already paid, and now we're trying to verify
course = self._create_course("verified")
self._enroll(course.id, "verified")
response = self._get_page('verify_student_verify_now', course.id)
self._assert_messaging(response, PayAndVerifyView.VERIFY_NOW_MSG)
# Expect that *all* steps are displayed,
# but we start after the payment step (because it's already completed).
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.FACE_PHOTO_STEP
)
# These will be hidden from the user anyway since they're starting
# after the payment step.
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_verify_now_already_verified(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status("submitted")
# Already verified, so if we somehow end up here,
# redirect immediately to the dashboard
response = self._get_page(
'verify_student_verify_now',
course.id,
expected_status_code=302
)
self._assert_redirects_to_dashboard(response)
def test_verify_now_user_details(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
response = self._get_page('verify_student_verify_now', course.id)
self._assert_user_details(response, self.user.profile.name)
@ddt.data(
"verify_student_verify_now",
"verify_student_verify_later",
"verify_student_payment_confirmation"
)
def test_verify_now_or_later_not_enrolled(self, page_name):
course = self._create_course("verified")
response = self._get_page(page_name, course.id, expected_status_code=302)
self._assert_redirects_to_start_flow(response, course.id)
@ddt.data(
"verify_student_verify_now",
"verify_student_verify_later",
"verify_student_payment_confirmation"
)
def test_verify_now_or_later_unenrolled(self, page_name):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._unenroll(course.id)
response = self._get_page(page_name, course.id, expected_status_code=302)
self._assert_redirects_to_start_flow(response, course.id)
@ddt.data(
"verify_student_verify_now",
"verify_student_verify_later",
"verify_student_payment_confirmation"
)
def test_verify_now_or_later_not_paid(self, page_name):
course = self._create_course("verified")
self._enroll(course.id, "honor")
response = self._get_page(page_name, course.id, expected_status_code=302)
self._assert_redirects_to_upgrade(response, course.id)
def test_verify_later(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
response = self._get_page("verify_student_verify_later", course.id)
self._assert_messaging(response, PayAndVerifyView.VERIFY_LATER_MSG)
# Expect that the payment steps are NOT displayed
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_PAYMENT,
PayAndVerifyView.INTRO_STEP
)
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
])
def test_verify_later_already_verified(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status("submitted")
# Already verified, so if we somehow end up here,
# redirect immediately to the dashboard
response = self._get_page(
'verify_student_verify_later',
course.id,
expected_status_code=302
)
self._assert_redirects_to_dashboard(response)
def test_payment_confirmation(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
response = self._get_page('verify_student_payment_confirmation', course.id)
self._assert_messaging(response, PayAndVerifyView.PAYMENT_CONFIRMATION_MSG)
# Expect that *all* steps are displayed,
# but we start at the payment confirmation step
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.PAYMENT_CONFIRMATION_STEP,
)
# These will be hidden from the user anyway since they're starting
# after the payment step. We're already including the payment
# steps, so it's easier to include these as well.
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_payment_confirmation_skip_first_step(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
response = self._get_page(
'verify_student_payment_confirmation',
course.id,
skip_first_step=True
)
self._assert_messaging(response, PayAndVerifyView.PAYMENT_CONFIRMATION_MSG)
# Expect that *all* steps are displayed,
# but we start on the first verify step
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.FACE_PHOTO_STEP,
)
def test_payment_confirmation_already_verified(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status("submitted")
response = self._get_page('verify_student_payment_confirmation', course.id)
# Other pages would redirect to the dashboard at this point,
# because the user has paid and verified. However, we want
# the user to see the confirmation page even if there
# isn't anything for them to do here except return
# to the dashboard.
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.PAYMENT_CONFIRMATION_STEP,
)
def test_payment_confirmation_already_verified_skip_first_step(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status("submitted")
response = self._get_page(
'verify_student_payment_confirmation',
course.id,
skip_first_step=True
)
# There are no other steps, so stay on the
# payment confirmation step
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.PAYMENT_CONFIRMATION_STEP,
)
@ddt.data(
(YESTERDAY, True),
(TOMORROW, False)
)
@ddt.unpack
def test_payment_confirmation_course_details(self, course_start, show_courseware_url):
course = self._create_course("verified", course_start=course_start)
self._enroll(course.id, "verified")
response = self._get_page('verify_student_payment_confirmation', course.id)
courseware_url = (
reverse("course_root", kwargs={'course_id': unicode(course.id)})
if show_courseware_url else ""
)
self._assert_course_details(
response,
unicode(course.id),
course.display_name,
course.start_datetime_text(),
courseware_url
)
@ddt.data("verified", "professional")
def test_upgrade(self, course_mode):
course = self._create_course(course_mode)
self._enroll(course.id, "honor")
response = self._get_page('verify_student_upgrade_and_verify', course.id)
self._assert_displayed_mode(response, course_mode)
self._assert_steps_displayed(
response,
PayAndVerifyView.ALL_STEPS,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.UPGRADE_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.PHOTO_ID_REQ,
PayAndVerifyView.WEBCAM_REQ,
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_upgrade_already_verified(self):
course = self._create_course("verified")
self._enroll(course.id, "honor")
self._set_verification_status("submitted")
response = self._get_page('verify_student_upgrade_and_verify', course.id)
self._assert_steps_displayed(
response,
PayAndVerifyView.STEPS_WITHOUT_VERIFICATION,
PayAndVerifyView.INTRO_STEP
)
self._assert_messaging(response, PayAndVerifyView.UPGRADE_MSG)
self._assert_requirements_displayed(response, [
PayAndVerifyView.CREDIT_CARD_REQ,
])
def test_upgrade_already_paid(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
# If we've already paid, then the upgrade messaging
# won't make much sense. Redirect them to the
# "verify later" page instead.
response = self._get_page(
'verify_student_upgrade_and_verify',
course.id,
expected_status_code=302
)
self._assert_redirects_to_verify_later(response, course.id)
def test_upgrade_already_verified_and_paid(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._set_verification_status("submitted")
# Already verified and paid, so redirect to the dashboard
response = self._get_page(
'verify_student_upgrade_and_verify',
course.id,
expected_status_code=302
)
self._assert_redirects_to_dashboard(response)
def test_upgrade_not_enrolled(self):
course = self._create_course("verified")
response = self._get_page(
'verify_student_upgrade_and_verify',
course.id,
expected_status_code=302
)
self._assert_redirects_to_start_flow(response, course.id)
def test_upgrade_unenrolled(self):
course = self._create_course("verified")
self._enroll(course.id, "verified")
self._unenroll(course.id)
response = self._get_page(
'verify_student_upgrade_and_verify',
course.id,
expected_status_code=302
)
self._assert_redirects_to_start_flow(response, course.id)
@ddt.data([], ["honor"], ["honor", "audit"])
def test_no_verified_mode_for_course(self, modes_available):
course = self._create_course(*modes_available)
pages = [
'verify_student_start_flow',
'verify_student_verify_now',
'verify_student_verify_later',
'verify_student_upgrade_and_verify',
]
for page_name in pages:
self._get_page(
page_name,
course.id,
expected_status_code=404
)
@ddt.data(
"verify_student_start_flow",
"verify_student_verify_now",
"verify_student_verify_later",
"verify_student_upgrade_and_verify",
)
def test_require_login(self, url_name):
self.client.logout()
course = self._create_course("verified")
response = self._get_page(url_name, course.id, expected_status_code=302)
original_url = reverse(url_name, kwargs={'course_id': unicode(course.id)})
login_url = u"{login_url}?next={original_url}".format(
login_url=reverse('accounts_login'),
original_url=original_url
)
self.assertRedirects(response, login_url)
@ddt.data(
"verify_student_start_flow",
"verify_student_verify_now",
"verify_student_verify_later",
"verify_student_upgrade_and_verify",
)
def test_no_such_course(self, url_name):
non_existent_course = CourseLocator(course="test", org="test", run="test")
self._get_page(
url_name,
non_existent_course,
expected_status_code=404
)
def _create_course(self, *course_modes, **kwargs):
"""Create a new course with the specified course modes. """
course = CourseFactory.create()
if kwargs.get('course_start'):
course.start = kwargs.get('course_start')
modulestore().update_item(course, ModuleStoreEnum.UserID.test)
for course_mode in course_modes:
min_price = (self.MIN_PRICE if course_mode != "honor" else 0)
CourseModeFactory(
course_id=course.id,
mode_slug=course_mode,
mode_display_name=course_mode,
min_price=min_price
)
return course
def _enroll(self, course_key, mode):
"""Enroll the user in a course. """
CourseEnrollmentFactory.create(
user=self.user,
course_id=course_key,
mode=mode
)
def _unenroll(self, course_key):
"""Unenroll the user from a course. """
CourseEnrollment.unenroll(self.user, course_key)
def _set_verification_status(self, status):
"""Set the user's photo verification status. """
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
if status in ["submitted", "approved", "expired", "denied", "error"]:
attempt.mark_ready()
attempt.submit()
if status in ["approved", "expired"]:
attempt.approve()
elif status == "denied":
attempt.deny("Denied!")
elif status == "error":
attempt.system_error("Error!")
if status == "expired":
days_good_for = settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]
attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1))
attempt.save()
def _get_page(self, url_name, course_key, expected_status_code=200, skip_first_step=False):
"""Retrieve one of the verification pages. """
url = reverse(url_name, kwargs={"course_id": unicode(course_key)})
if skip_first_step:
url += "?skip-first-step=1"
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)
return response
def _assert_displayed_mode(self, response, expected_mode):
"""Check whether a course mode is displayed. """
response_dict = self._get_page_data(response)
self.assertEqual(response_dict['course_mode_slug'], expected_mode)
def _assert_steps_displayed(self, response, expected_steps, expected_current_step):
"""Check whether steps in the flow are displayed to the user. """
response_dict = self._get_page_data(response)
self.assertEqual(response_dict['current_step'], expected_current_step)
self.assertEqual(expected_steps, [
step['name'] for step in
response_dict['display_steps']
])
def _assert_messaging(self, response, expected_message):
"""Check the messaging on the page. """
response_dict = self._get_page_data(response)
self.assertEqual(response_dict['message_key'], expected_message)
def _assert_requirements_displayed(self, response, requirements):
"""Check that requirements are displayed on the page. """
response_dict = self._get_page_data(response)
for req, displayed in response_dict['requirements'].iteritems():
if req in requirements:
self.assertTrue(displayed, msg="Expected '{req}' requirement to be displayed".format(req=req))
else:
self.assertFalse(displayed, msg="Expected '{req}' requirement to be hidden".format(req=req))
def _assert_course_details(self, response, course_key, display_name, start_text, url):
"""Check the course information on the page. """
response_dict = self._get_page_data(response)
self.assertEqual(response_dict['course_key'], course_key)
self.assertEqual(response_dict['course_name'], display_name)
self.assertEqual(response_dict['course_start_date'], start_text)
self.assertEqual(response_dict['courseware_url'], url)
def _assert_user_details(self, response, full_name):
"""Check the user detail information on the page. """
response_dict = self._get_page_data(response)
self.assertEqual(response_dict['full_name'], full_name)
def _get_page_data(self, response):
"""Retrieve the data attributes rendered on the page. """
soup = BeautifulSoup(response.content)
pay_and_verify_div = soup.find(id="pay-and-verify-container")
return {
'full_name': pay_and_verify_div['data-full-name'],
'course_key': pay_and_verify_div['data-course-key'],
'course_name': pay_and_verify_div['data-course-name'],
'course_start_date': pay_and_verify_div['data-course-start-date'],
'courseware_url': pay_and_verify_div['data-courseware-url'],
'course_mode_name': pay_and_verify_div['data-course-mode-name'],
'course_mode_slug': pay_and_verify_div['data-course-mode-slug'],
'display_steps': json.loads(pay_and_verify_div['data-display-steps']),
'current_step': pay_and_verify_div['data-current-step'],
'requirements': json.loads(pay_and_verify_div['data-requirements']),
'message_key': pay_and_verify_div['data-msg-key']
}
def _assert_redirects_to_dashboard(self, response):
"""Check that the page redirects to the student dashboard. """
self.assertRedirects(response, reverse('dashboard'))
def _assert_redirects_to_start_flow(self, response, course_id):
"""Check that the page redirects to the start of the payment/verification flow. """
url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_id)})
self.assertRedirects(response, url)
def _assert_redirects_to_verify_later(self, response, course_id):
"""Check that the page redirects to the "verify later" part of the flow. """
url = reverse('verify_student_verify_later', kwargs={'course_id': unicode(course_id)})
self.assertRedirects(response, url)
def _assert_redirects_to_upgrade(self, response, course_id):
"""Check that the page redirects to the "upgrade" part of the flow. """
url = reverse('verify_student_upgrade_and_verify', kwargs={'course_id': unicode(course_id)})
self.assertRedirects(response, url)
from django.conf.urls import patterns, url
from verify_student import views
from verify_student.views import PayAndVerifyView
from django.conf import settings
......@@ -82,3 +83,85 @@ urlpatterns = patterns(
name="verify_student_toggle_failed_banner_off"
),
)
if settings.FEATURES.get("SEPARATE_VERIFICATION_FROM_PAYMENT"):
urlpatterns += patterns(
'',
url(
r'^submit-photos/$',
views.submit_photos_for_verification,
name="verify_student_submit_photos"
),
# The user is starting the verification / payment process,
# most likely after enrolling in a course and selecting
# a "verified" track.
url(
r'^start-flow/{course}/$'.format(course=settings.COURSE_ID_PATTERN),
views.PayAndVerifyView.as_view(), # pylint: disable=no-value-for-parameter
name="verify_student_start_flow",
kwargs={
'message': PayAndVerifyView.FIRST_TIME_VERIFY_MSG
}
),
# The user is enrolled in a non-paid mode and wants to upgrade.
# This is the same as the "start verification" flow,
# except with slight messaging changes.
url(
r'^upgrade/{course}/$'.format(course=settings.COURSE_ID_PATTERN),
views.PayAndVerifyView.as_view(), # pylint: disable=no-value-for-parameter
name="verify_student_upgrade_and_verify",
kwargs={
'message': PayAndVerifyView.UPGRADE_MSG
}
),
# The user has paid and still needs to verify.
# Since the user has "just paid", we display *all* steps
# including payment. The user resumes the flow
# from the verification step.
# Note that if the user has already verified, this will redirect
# to the dashboard.
url(
r'^verify-now/{course}/$'.format(course=settings.COURSE_ID_PATTERN),
views.PayAndVerifyView.as_view(), # pylint: disable=no-value-for-parameter
name="verify_student_verify_now",
kwargs={
'always_show_payment': True,
'current_step': PayAndVerifyView.FACE_PHOTO_STEP,
'message': PayAndVerifyView.VERIFY_NOW_MSG
}
),
# The user has paid and still needs to verify,
# but the user is NOT arriving directly from the paymen104ggt flow.
# This is equivalent to starting a new flow
# with the payment steps and requirements hidden
# (since the user already paid).
url(
r'^verify-later/{course}/$'.format(course=settings.COURSE_ID_PATTERN),
views.PayAndVerifyView.as_view(), # pylint: disable=no-value-for-parameter
name="verify_student_verify_later",
kwargs={
'message': PayAndVerifyView.VERIFY_LATER_MSG
}
),
# The user is returning to the flow after paying.
# This usually occurs after a redirect from the shopping cart
# once the order has been fulfilled.
url(
r'^payment-confirmation/{course}/$'.format(course=settings.COURSE_ID_PATTERN),
views.PayAndVerifyView.as_view(), # pylint: disable=no-value-for-parameter
name="verify_student_payment_confirmation",
kwargs={
'always_show_payment': True,
'current_step': PayAndVerifyView.PAYMENT_CONFIRMATION_STEP,
'message': PayAndVerifyView.PAYMENT_CONFIRMATION_MSG
}
),
)
......@@ -6,21 +6,29 @@ import json
import logging
import decimal
import datetime
from collections import namedtuple
from pytz import UTC
from edxmako.shortcuts import render_to_response
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.http import (
HttpResponse, HttpResponseBadRequest,
HttpResponseRedirect, Http404
)
from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic.base import View
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.auth.decorators import login_required
from staticfiles.storage import staticfiles_storage
from openedx.core.djangoapps.user_api.api import profile as profile_api
from course_modes.models import CourseMode
from student.models import CourseEnrollment
from student.views import reverification_info
......@@ -167,13 +175,594 @@ class VerifiedView(View):
return render_to_response('verify_student/verified.html', context)
class PayAndVerifyView(View):
"""View for the "verify and pay" flow.
This view is somewhat complicated, because the user
can enter it from a number of different places:
* From the "choose your track" page.
* After completing payment.
* From the dashboard in order to complete verification.
* From the dashboard in order to upgrade to a verified track.
The page will display different steps and requirements
depending on:
* Whether the user has submitted a photo verification recently.
* Whether the user has paid for the course.
* How the user reached the page (mostly affects messaging)
We are also super-paranoid about how users reach this page.
If they somehow aren't enrolled, or the course doesn't exist,
or they've unenrolled, or they've already paid/verified,
... then we try to redirect them to the page with the
most appropriate messaging (including the dashboard).
Note that this page does NOT handle re-verification
(photo verification that was denied or had an error);
that is handled by the "reverify" view.
"""
# Step definitions
#
# These represent the numbered steps a user sees in
# the verify / payment flow.
#
# Steps can either be:
# - displayed or hidden
# - complete or incomplete
#
# For example, when a user enters the verification/payment
# flow for the first time, the user will see steps
# for both payment and verification. As the user
# completes these steps (for example, submitting a photo)
# the steps will be marked "complete".
#
# If a user has already verified for another course,
# then the verification steps will be hidden,
# since the user has already completed them.
#
# If a user re-enters the flow from another application
# (for example, after completing payment through
# a third-party payment processor), then the user
# will resume the flow at an intermediate step.
#
INTRO_STEP = 'intro-step'
MAKE_PAYMENT_STEP = 'make-payment-step'
PAYMENT_CONFIRMATION_STEP = 'payment-confirmation-step'
FACE_PHOTO_STEP = 'face-photo-step'
ID_PHOTO_STEP = 'id-photo-step'
REVIEW_PHOTOS_STEP = 'review-photos-step'
ENROLLMENT_CONFIRMATION_STEP = 'enrollment-confirmation-step'
ALL_STEPS = [
INTRO_STEP,
MAKE_PAYMENT_STEP,
PAYMENT_CONFIRMATION_STEP,
FACE_PHOTO_STEP,
ID_PHOTO_STEP,
REVIEW_PHOTOS_STEP,
ENROLLMENT_CONFIRMATION_STEP
]
PAYMENT_STEPS = [
MAKE_PAYMENT_STEP,
PAYMENT_CONFIRMATION_STEP
]
VERIFICATION_STEPS = [
FACE_PHOTO_STEP,
ID_PHOTO_STEP,
REVIEW_PHOTOS_STEP,
ENROLLMENT_CONFIRMATION_STEP
]
STEPS_WITHOUT_PAYMENT = [
step for step in ALL_STEPS
if step not in PAYMENT_STEPS
]
STEPS_WITHOUT_VERIFICATION = [
step for step in ALL_STEPS
if step not in VERIFICATION_STEPS
]
Step = namedtuple(
'Step',
[
'title',
'template_name'
]
)
STEP_INFO = {
INTRO_STEP: Step(
title=ugettext_lazy("Intro"),
template_name="verify_student/intro_step.underscore"
),
MAKE_PAYMENT_STEP: Step(
title=ugettext_lazy("Make Payment"),
template_name="verify_student/make_payment_step.underscore"
),
PAYMENT_CONFIRMATION_STEP: Step(
title=ugettext_lazy("Payment Confirmation"),
template_name="verify_student/payment_confirmation_step.underscore"
),
FACE_PHOTO_STEP: Step(
title=ugettext_lazy("Take Face Photo"),
template_name="verify_student/face_photo_step.underscore"
),
ID_PHOTO_STEP: Step(
title=ugettext_lazy("ID Photo"),
template_name="verify_student/id_photo_step.underscore"
),
REVIEW_PHOTOS_STEP: Step(
title=ugettext_lazy("Review Photos"),
template_name="verify_student/review_photos_step.underscore"
),
ENROLLMENT_CONFIRMATION_STEP: Step(
title=ugettext_lazy("Enrollment Confirmation"),
template_name="verify_student/enrollment_confirmation_step.underscore"
),
}
# Messages
#
# Depending on how the user entered reached the page,
# we will display different text messaging.
# For example, we show users who are upgrading
# slightly different copy than users who are verifying
# for the first time.
#
FIRST_TIME_VERIFY_MSG = 'first-time-verify'
VERIFY_NOW_MSG = 'verify-now'
VERIFY_LATER_MSG = 'verify-later'
UPGRADE_MSG = 'upgrade'
PAYMENT_CONFIRMATION_MSG = 'payment-confirmation'
Message = namedtuple(
'Message',
[
'page_title',
'top_level_msg',
'status_msg',
'intro_title',
'intro_msg'
]
)
MESSAGES = {
FIRST_TIME_VERIFY_MSG: Message(
page_title=ugettext_lazy("Enroll In {course_name}"),
top_level_msg=ugettext_lazy("Congrats! You are now enrolled in {course_name}."),
status_msg=ugettext_lazy("Enrolling as"),
intro_title=ugettext_lazy("What You Will Need To Enroll"),
intro_msg=ugettext_lazy("There are {num_requirements} things you will need to enroll in the {course_mode} track.")
),
VERIFY_NOW_MSG: Message(
page_title=ugettext_lazy("Enroll In {course_name}"),
top_level_msg=ugettext_lazy("Congrats! You are now enrolled in {course_name}."),
status_msg=ugettext_lazy("Enrolled as"),
intro_title=ugettext_lazy("What You Will Need To Enroll"),
intro_msg=ugettext_lazy("There are {num_requirements} things you will need to enroll in the {course_mode} track.")
),
VERIFY_LATER_MSG: Message(
page_title=ugettext_lazy("Enroll In {course_name}"),
top_level_msg=ugettext_lazy("Congrats! You are now enrolled in {course_name}."),
status_msg=ugettext_lazy("Enrolled as"),
intro_title=ugettext_lazy("What You Will Need To Verify"),
intro_msg=ugettext_lazy("There are {num_requirements} things you will need to complete verification.")
),
UPGRADE_MSG: Message(
page_title=ugettext_lazy("Upgrade Your Enrollment For {course_name}."),
top_level_msg=ugettext_lazy("You are upgrading your enrollment for {course_name}."),
status_msg=ugettext_lazy("Upgrading to"),
intro_title=ugettext_lazy("What You Will Need To Upgrade"),
intro_msg=ugettext_lazy("There are {num_requirements} things you will need to complete upgrade to the {course_mode} track.")
),
PAYMENT_CONFIRMATION_MSG: Message(
page_title=ugettext_lazy("Payment Confirmation"),
top_level_msg=ugettext_lazy("You are now enrolled in {course_name}."),
status_msg=ugettext_lazy("Enrolled as"),
intro_title="",
intro_msg=""
)
}
# Requirements
#
# These explain to the user what he or she
# will need to successfully pay and/or verify.
#
# These are determined by the steps displayed
# to the user; for example, if the user does not
# need to complete the verification steps,
# then the photo ID and webcam requirements are hidden.
#
PHOTO_ID_REQ = "photo-id-required"
WEBCAM_REQ = "webcam-required"
CREDIT_CARD_REQ = "credit-card-required"
STEP_REQUIREMENTS = {
ID_PHOTO_STEP: [PHOTO_ID_REQ, WEBCAM_REQ],
FACE_PHOTO_STEP: [WEBCAM_REQ],
MAKE_PAYMENT_STEP: [CREDIT_CARD_REQ],
}
@method_decorator(login_required)
def get(
self, request, course_id,
always_show_payment=False,
current_step=INTRO_STEP,
message=FIRST_TIME_VERIFY_MSG
):
"""Render the pay/verify requirements page.
Arguments:
request (HttpRequest): The request object.
course_id (unicode): The ID of the course the user is trying
to enroll in.
Keyword Arguments:
always_show_payment (bool): If True, show the payment steps
even if the user has already paid. This is useful
for users returning to the flow after paying.
current_step (string): The current step in the flow.
message (string): The messaging to display.
Returns:
HttpResponse
Raises:
Http404: The course does not exist or does not
have a verified mode.
"""
# Parse the course key
# The URL regex should guarantee that the key format is valid.
course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key)
# Verify that the course exists and has a verified mode
if course is None:
raise Http404
# Verify that the course has a verified mode
course_mode = CourseMode.verified_mode_for_course(course_key)
if course_mode is None:
raise Http404
# Check whether the user has verified, paid, and enrolled.
# A user is considered "paid" if he or she has an enrollment
# with a paid course mode (such as "verified").
# For this reason, every paid user is enrolled, but not
# every enrolled user is paid.
already_verified = self._check_already_verified(request.user)
already_paid, is_enrolled = self._check_enrollment(request.user, course_key)
# Redirect the user to a more appropriate page if the
# messaging won't make sense based on the user's
# enrollment / payment / verification status.
redirect_response = self._redirect_if_necessary(
message,
already_verified,
already_paid,
is_enrolled,
course_key
)
if redirect_response is not None:
return redirect_response
display_steps = self._display_steps(
always_show_payment,
already_verified,
already_paid
)
requirements = self._requirements(display_steps)
# Allow the caller to skip the first page
# This is useful if we want the user to be able to
# use the "back" button to return to the previous step.
if request.GET.get('skip-first-step'):
display_step_names = [step['name'] for step in display_steps]
current_step_idx = display_step_names.index(current_step)
if (current_step_idx + 1) < len(display_steps):
current_step = display_steps[current_step_idx + 1]['name']
courseware_url = ""
if not course.start or course.start < datetime.datetime.today().replace(tzinfo=UTC):
courseware_url = reverse(
'course_root',
kwargs={'course_id': unicode(course_key)}
)
full_name = (
request.user.profile.name
if request.user.profile.name
else ""
)
# Render the top-level page
context = {
'disable_courseware_js': True,
'user_full_name': full_name,
'platform_name': settings.PLATFORM_NAME,
'course_key': unicode(course_key),
'course': course,
'courseware_url': courseware_url,
'course_mode': course_mode,
'purchase_endpoint': get_purchase_endpoint(),
'display_steps': display_steps,
'current_step': current_step,
'requirements': requirements,
'message_key': message,
'messages': self._messages(
message,
course.display_name,
course_mode,
requirements
),
}
return render_to_response("verify_student/pay_and_verify.html", context)
def _redirect_if_necessary(
self,
message,
already_verified,
already_paid,
is_enrolled,
course_key
):
"""Redirect the user to a more appropriate page if necessary.
In some cases, a user may visit this page with
verification / enrollment / payment state that
we don't anticipate. For example, a user may unenroll
from the course after paying for it, then visit the
"verify now" page to complete verification.
When this happens, we try to redirect the user to
the most appropriate page.
Arguments:
message (string): The messaging of the page. Should be a key
in `MESSAGES`.
already_verified (bool): Whether the user has submitted
a verification request recently.
already_paid (bool): Whether the user is enrolled in a paid
course mode.
is_enrolled (bool): Whether the user has an active enrollment
in the course.
course_key (CourseKey): The key for the course.
Returns:
HttpResponse or None
"""
url = None
course_kwargs = {'course_id': unicode(course_key)}
if already_verified and already_paid:
# If they've already paid and verified, there's nothing else to do,
# so redirect them to the dashboard.
if message != self.PAYMENT_CONFIRMATION_MSG:
url = reverse('dashboard')
elif message in [self.VERIFY_NOW_MSG, self.VERIFY_LATER_MSG, self.PAYMENT_CONFIRMATION_MSG]:
if is_enrolled:
# If the user is already enrolled but hasn't yet paid,
# then the "upgrade" messaging is more appropriate.
if not already_paid:
url = reverse('verify_student_upgrade_and_verify', kwargs=course_kwargs)
else:
# If the user is NOT enrolled, then send him/her
# to the first time verification page.
url = reverse('verify_student_start_flow', kwargs=course_kwargs)
elif message == self.UPGRADE_MSG:
if is_enrolled:
# If upgrading and we've paid but haven't verified,
# then the "verify later" messaging makes more sense.
if already_paid:
url = reverse('verify_student_verify_later', kwargs=course_kwargs)
else:
url = reverse('verify_student_start_flow', kwargs=course_kwargs)
# Redirect if necessary, otherwise implicitly return None
if url is not None:
return redirect(url)
def _display_steps(self, always_show_payment, already_verified, already_paid):
"""Determine which steps to display to the user.
Includes all steps by default, but removes steps
if the user has already completed them.
Arguments:
always_show_payment (bool): If True, display the payment steps
even if the user has already paid.
already_verified (bool): Whether the user has submitted
a verification request recently.
already_paid (bool): Whether the user is enrolled in a paid
course mode.
Returns:
list
"""
display_steps = self.ALL_STEPS
remove_steps = set()
if already_verified:
remove_steps |= set(self.VERIFICATION_STEPS)
if already_paid and not always_show_payment:
remove_steps |= set(self.PAYMENT_STEPS)
return [
{
'name': step,
'title': unicode(self.STEP_INFO[step].title),
'templateUrl': self._template_url(self.STEP_INFO[step].template_name)
}
for step in display_steps
if step not in remove_steps
]
def _template_url(self, template_name):
"""Determine the path to a template.
This uses staticfiles, so the path will include MD5
hashes when used in production. This is really important,
because otherwise the JavaScript won't be able to find
the templates!
Arguments:
template_name (str): The name of the template, relative
to the "static/templates" directory.
Returns:
string
"""
template_path = u"templates/{name}".format(name=template_name)
return (
staticfiles_storage.url(template_path)
if template_name is not None else ""
)
def _requirements(self, display_steps):
"""Determine which requirements to show the user.
For example, if the user needs to submit a photo
verification, tell the user that she will need
a photo ID and a webcam.
Arguments:
display_steps (list): The steps to display to the user.
Returns:
dict: Keys are requirement names, values are booleans
indicating whether to show the requirement.
"""
all_requirements = {
self.PHOTO_ID_REQ: False,
self.WEBCAM_REQ: False,
self.CREDIT_CARD_REQ: False
}
display_steps = set(step['name'] for step in display_steps)
for step, step_requirements in self.STEP_REQUIREMENTS.iteritems():
if step in display_steps:
for requirement in step_requirements:
all_requirements[requirement] = True
return all_requirements
def _messages(self, message_key, course_name, course_mode, requirements):
"""Construct messages based on how the user arrived at the page.
Arguments:
message_key (string): One of the keys in `MESSAGES`.
course_name (unicode): The name of the course the user wants to enroll in.
course_mode (CourseMode): The course mode for the course.
requirements (dict): The requirements for verifying and/or paying.
Returns:
`Message` (namedtuple)
"""
messages = self.MESSAGES[message_key]
# Count requirements
num_requirements = sum([
1 if requirement else 0
for requirement in requirements.values()
])
context = {
'course_name': course_name,
'course_mode': course_mode.name,
'num_requirements': num_requirements
}
# Interpolate the course name / mode into messaging strings
# Implicitly force lazy translations to unicode
return self.Message(
**{
key: value.format(**context)
for key, value in messages._asdict().iteritems() # pylint: disable=protected-access
}
)
def _check_already_verified(self, user):
"""Check whether the user has a valid or pending verification.
Note that this includes cases in which the user's verification
has not been accepted (either because it hasn't been processed,
or there was an error).
This should return True if the user has done their part:
submitted photos within the expiration period.
"""
return SoftwareSecurePhotoVerification.user_has_valid_or_pending(user)
def _check_enrollment(self, user, course_key):
"""Check whether the user has an active enrollment and has paid.
If a user is enrolled in a paid course mode, we assume
that the user has paid.
Arguments:
user (User): The user to check.
course_key (CourseKey): The key of the course to check.
Returns:
Tuple `(has_paid, is_active)` indicating whether the user
has paid and whether the user has an active account.
"""
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_key)
has_paid = False
if enrollment_mode is not None and is_active:
all_modes = CourseMode.modes_for_course_dict(course_key)
course_mode = all_modes.get(enrollment_mode)
has_paid = (course_mode and course_mode.min_price > 0)
return (has_paid, bool(is_active))
@require_POST
@login_required
def create_order(request):
"""
Submit PhotoVerification and create a new Order for this verified cert
"""
if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
# TODO (ECOM-188): Once the A/B test of separating the payment/verified flow
# has completed, we can remove this flag and delete the photo verification
# step entirely (since it will be handled in a separate view).
submit_photo = True
if settings.FEATURES.get("SEPARATE_VERIFICATION_FROM_PAYMENT"):
submit_photo = (
'face_image' in request.POST and
'photo_id_image' in request.POST
)
if (
submit_photo and not
SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user)
):
attempt = SoftwareSecurePhotoVerification(user=request.user)
try:
b64_face_image = request.POST['face_image'].split(",")[1]
......@@ -246,6 +835,63 @@ def create_order(request):
@require_POST
@login_required
def submit_photos_for_verification(request):
"""Submit a photo verification attempt.
Arguments:
request (HttpRequest): The request to submit photos.
Returns:
HttpResponse: 200 on success, 400 if there are errors.
"""
# Check the required parameters
missing_params = set(['face_image', 'photo_id_image']) - set(request.POST.keys())
if len(missing_params) > 0:
msg = _("Missing required parameters: {missing}").format(missing=", ".join(missing_params))
return HttpResponseBadRequest(msg)
# If the user already has valid or pending request, the UI will hide
# the verification steps. For this reason, we reject any requests
# for users that already have a valid or pending verification.
if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
return HttpResponseBadRequest(_("You already have a valid or pending verification."))
# If the user wants to change his/her full name,
# then try to do that before creating the attempt.
if request.POST.get('full_name'):
try:
profile_api.update_profile(
request.user.username,
full_name=request.POST.get('full_name')
)
except profile_api.ProfileUserNotFound:
return HttpResponseBadRequest(_("No profile found for user"))
except profile_api.ProfileInvalidField:
msg = _(
"Name must be at least {min_length} characters long."
).format(min_length=profile_api.FULL_NAME_MIN_LENGTH)
return HttpResponseBadRequest(msg)
# Create the attempt
attempt = SoftwareSecurePhotoVerification(user=request.user)
try:
b64_face_image = request.POST['face_image'].split(",")[1]
b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
except IndexError:
msg = _("Image data is not valid.")
return HttpResponseBadRequest(msg)
attempt.upload_face_image(b64_face_image.decode('base64'))
attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
attempt.mark_ready()
attempt.submit()
return HttpResponse(200)
@require_POST
@csrf_exempt # SS does its own message signing, and their API won't have a cookie value
def results_callback(request):
"""
......
......@@ -1010,12 +1010,12 @@ courseware_js = (
base_vendor_js = [
'js/vendor/jquery.min.js',
'js/vendor/jquery.cookie.js',
'js/vendor/underscore-min.js'
'js/vendor/underscore-min.js',
'js/vendor/require.js',
'js/RequireJS-namespace-undefine.js',
]
main_vendor_js = base_vendor_js + [
'js/vendor/require.js',
'js/RequireJS-namespace-undefine.js',
'js/vendor/json2.js',
'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.qtip.min.js',
......@@ -1038,7 +1038,15 @@ instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/ins
student_account_js = [
'js/utils/rwd_header_footer.js',
'js/utils/edx.utils.validate.js',
'js/form.ext.js',
'js/my_courses_dropdown.js',
'js/toggle_login_modal.js',
'js/sticky_filter.js',
'js/query-params.js',
'js/src/utility.js',
'js/src/accessibility_tools.js',
'js/src/ie_shim.js',
'js/src/string_utils.js',
'js/student_account/enrollment.js',
'js/student_account/emailoptin.js',
'js/student_account/shoppingcart.js',
......@@ -1055,6 +1063,32 @@ student_account_js = [
student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js'))
verify_student_js = [
'js/form.ext.js',
'js/my_courses_dropdown.js',
'js/toggle_login_modal.js',
'js/sticky_filter.js',
'js/query-params.js',
'js/src/utility.js',
'js/src/accessibility_tools.js',
'js/src/ie_shim.js',
'js/src/string_utils.js',
'js/verify_student/models/verification_model.js',
'js/verify_student/views/error_view.js',
'js/verify_student/views/webcam_photo_view.js',
'js/verify_student/views/progress_view.js',
'js/verify_student/views/step_view.js',
'js/verify_student/views/intro_step_view.js',
'js/verify_student/views/make_payment_step_view.js',
'js/verify_student/views/payment_confirmation_step_view.js',
'js/verify_student/views/face_photo_step_view.js',
'js/verify_student/views/id_photo_step_view.js',
'js/verify_student/views/review_photos_step_view.js',
'js/verify_student/views/enrollment_confirmation_step_view.js',
'js/verify_student/views/pay_and_verify_view.js',
'js/verify_student/pay_and_verify.js',
]
PIPELINE_CSS = {
'style-vendor': {
'source_filenames': [
......@@ -1234,6 +1268,10 @@ PIPELINE_JS = {
'source_filenames': student_profile_js,
'output_filename': 'js/student_profile.js'
},
'verify_student': {
'source_filenames': verify_student_js,
'output_filename': 'js/verify_student.js'
}
}
PIPELINE_DISABLE_WRAPPER = True
......
/**
* In-memory storage of verification photo data.
*
* This can be passed to multiple steps in the workflow
* to persist image data in-memory before it is submitted
* to the server.
*
*/
var edx = edx || {};
(function( $, _, Backbone ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.VerificationModel = Backbone.Model.extend({
defaults: {
fullName: null,
faceImage: "",
identificationImage: ""
},
sync: function( method, model ) {
var headers = { 'X-CSRFToken': $.cookie('csrftoken') },
data = {
face_image: model.get('faceImage'),
photo_id_image: model.get('identificationImage')
};
// Full name is an optional parameter; if not provided,
// it won't be changed.
if ( !_.isNull( model.get('fullName') ) ) {
data.full_name = model.get('fullName');
}
// Submit the request to the server,
// triggering events on success and error.
$.ajax({
url: '/verify_student/submit-photos/',
type: 'POST',
data: data,
headers: headers,
success: function() {
model.trigger( 'sync' );
},
error: function( error ) {
model.trigger( 'error', error );
}
});
}
});
})( jQuery, _, Backbone );
/**
* Entry point for the payment/verification flow.
* This loads the base view, which in turn loads
* subviews for each step in the flow.
*
* We pass some information to the base view
* using "data-" attributes on the parent div.
* See "pay_and_verify.html" for the exact attribute names.
*
*/
var edx = edx || {};
(function($) {
'use strict';
var errorView,
el = $('#pay-and-verify-container');
edx.verify_student = edx.verify_student || {};
// Initialize an error view for displaying top-level error messages.
errorView = new edx.verify_student.ErrorView({
el: $('#error-container')
});
// Initialize the base view, passing in information
// from the data attributes on the parent div.
//
// The data attributes capture information that only
// the server knows about, such as the course and course mode info,
// full URL paths to static underscore templates,
// and some messaging.
//
return new edx.verify_student.PayAndVerifyView({
errorModel: errorView.model,
displaySteps: el.data('display-steps'),
currentStep: el.data('current-step'),
stepInfo: {
'intro-step': {
introTitle: el.data('intro-title'),
introMsg: el.data('intro-msg'),
requirements: el.data('requirements')
},
'make-payment-step': {
courseKey: el.data('course-key'),
minPrice: el.data('course-mode-min-price'),
suggestedPrices: (el.data('course-mode-suggested-prices') || "").split(","),
currency: el.data('course-mode-currency'),
purchaseEndpoint: el.data('purchase-endpoint')
},
'payment-confirmation-step': {
courseName: el.data('course-name'),
courseStartDate: el.data('course-start-date'),
coursewareUrl: el.data('courseware-url')
},
'review-photos-step': {
fullName: el.data('full-name'),
platformName: el.data('platform-name')
}
}
}).render();
})(jQuery);
/**
* View for the "enrollment confirmation" step of
* the payment/verification flow.
*/
var edx = edx || {};
(function( $ ) {
'use strict';
edx.verify_student = edx.verify_student || {};
// Currently, this step does not need to install any event handlers,
// since the displayed information is static.
edx.verify_student.EnrollmentConfirmationStepView = edx.verify_student.StepView.extend({});
})( jQuery );
/**
* Display top-level errors in the payment/verification flow.
*/
var edx = edx || {};
(function ( $, _, Backbone ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ErrorView = Backbone.View.extend({
initialize: function( obj ) {
var ErrorModel = Backbone.Model.extend({});
this.model = obj.model || new ErrorModel({
errorTitle: "",
errorMsg: "",
shown: false
});
this.listenToOnce( this.model, 'change', this.render );
},
render: function() {
var renderedHtml = _.template(
$( '#error-tpl' ).html(),
{
errorTitle: this.model.get( 'errorTitle' ),
errorMsg: this.model.get( 'errorMsg' )
}
);
$( this.el ).html( renderedHtml );
if ( this.model.get( 'shown' ) ) {
$( this.el ).show();
$( "html, body" ).animate({ scrollTop: 0 });
}
else {
$( this.el ).hide();
}
}
});
})( $, _, Backbone );
/**
* View for the "face photo" step in the payment/verification flow.
*/
var edx = edx || {};
(function( $ ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.FacePhotoStepView = edx.verify_student.StepView.extend({
postRender: function() {
new edx.verify_student.WebcamPhotoView({
el: $("#facecam"),
model: this.model,
modelAttribute: 'faceImage',
submitButton: '#next_step_button',
errorModel: this.errorModel
}).render();
$('#next_step_button').on( 'click', _.bind( this.nextStep, this ) );
},
});
})( jQuery );
/**
* View for the "id photo" step of the payment/verification flow.
*/
var edx = edx || {};
(function( $ ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.IDPhotoStepView = edx.verify_student.StepView.extend({
postRender: function() {
new edx.verify_student.WebcamPhotoView({
el: $("#idcam"),
model: this.model,
modelAttribute: 'identificationImage',
submitButton: '#next_step_button',
errorModel: this.errorModel
}).render();
$('#next_step_button').on( 'click', _.bind( this.nextStep, this ) );
},
});
})( jQuery );
/**
* View for the "intro step" of the payment/verification flow.
*/
var edx = edx || {};
(function( $ ) {
'use strict';
edx.verify_student = edx.verify_student || {};
// Currently, this view doesn't need to install any custom event handlers,
// since the button in the template reloads the page with a
// ?skip-intro=1 GET parameter. The reason for this is that we
// want to allow users to click "back" to see the requirements,
// and if they reload the page we want them to stay on the
// second step.
edx.verify_student.IntroStepView = edx.verify_student.StepView.extend({});
})( jQuery );
/**
* View for the "make payment" step of the payment/verification flow.
*/
var edx = edx || {};
(function( $, _, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.MakePaymentStepView = edx.verify_student.StepView.extend({
postRender: function() {
// Enable the payment button once an amount is chosen
$( "input[name='contribution']" ).on( 'click', _.bind( this.enablePaymentButton, this ) );
// Handle payment submission
$( "#pay_button" ).on( 'click', _.bind( this.createOrder, this ) );
},
enablePaymentButton: function() {
$("#pay_button").removeClass("is-disabled");
},
createOrder: function() {
var paymentAmount = this.getPaymentAmount(),
postData = {
'contribution': paymentAmount,
'course_id': this.stepData.courseKey,
};
// Disable the payment button to prevent multiple submissions
$("#pay_button").addClass("is-disabled");
// Create the order for the amount
$.ajax({
url: '/verify_student/create_order/',
type: 'POST',
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
data: postData,
context: this,
success: this.handleCreateOrderResponse,
error: this.handleCreateOrderError
});
},
handleCreateOrderResponse: function( paymentParams ) {
// At this point, the order has been created on the server,
// and we've received signed payment parameters.
// We need to dynamically construct a form using
// these parameters, then submit it to the payment processor.
// This will send the user to a hosted order page,
// where she can enter credit card information.
var form = $( "#payment-processor-form" );
$( "input", form ).remove();
form.attr( "action", this.stepData.purchaseEndpoint );
form.attr( "method", "POST" );
_.each( paymentParams, function( value, key ) {
$("<input>").attr({
type: "hidden",
name: key,
value: value
}).appendTo(form);
});
form.submit();
},
handleCreateOrderError: function( xhr ) {
if ( xhr.status === 400 ) {
this.errorModel.set({
errorTitle: gettext( 'Could not submit order' ),
errorMsg: xhr.responseText,
shown: true
});
} else {
this.errorModel.set({
errorTitle: gettext( 'Could not submit order' ),
errorMsg: gettext( 'An unexpected error occurred. Please try again' ),
shown: true
});
}
// Re-enable the button so the user can re-try
$( "#payment-processor-form" ).removeClass("is-disabled");
},
getPaymentAmount: function() {
var contributionInput = $("input[name='contribution']:checked", this.el);
if ( contributionInput.attr('id') === 'contribution-other' ) {
return $( "input[name='contribution-other-amt']", this.el ).val();
} else {
return contributionInput.val();
}
}
});
})( jQuery, _, gettext );
/**
* Base view for the payment/verification flow.
*
* This view is responsible for the "progress steps"
* at the top of the page, but it delegates
* to subviews to render individual steps.
*
*/
var edx = edx || {};
(function($, _, Backbone, gettext) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.PayAndVerifyView = Backbone.View.extend({
el: '#pay-and-verify-container',
template: '#progress-tpl',
subviews: {},
VERIFICATION_VIEW_NAMES: [
'face-photo-step',
'id-photo-step',
'review-photos-step'
],
initialize: function( obj ) {
this.errorModel = obj.errorModel || {};
this.displaySteps = obj.displaySteps || [];
// Determine which step we're starting on
// Depending on how the user enters the flow,
// this could be anywhere in the sequence of steps.
this.currentStepIndex = _.indexOf(
_.pluck( this.displaySteps, 'name' ),
obj.currentStep
);
this.progressView = new edx.verify_student.ProgressView({
el: this.el,
displaySteps: this.displaySteps,
currentStepIndex: this.currentStepIndex
});
this.initializeStepViews( obj.stepInfo );
},
initializeStepViews: function( stepInfo ) {
var i,
stepName,
stepData,
subview,
subviewConfig,
nextStepTitle,
subviewConstructors,
verificationModel;
// We need to initialize this here, because
// outside of this method the subview classes
// might not yet have been loaded.
subviewConstructors = {
'intro-step': edx.verify_student.IntroStepView,
'make-payment-step': edx.verify_student.MakePaymentStepView,
'payment-confirmation-step': edx.verify_student.PaymentConfirmationStepView,
'face-photo-step': edx.verify_student.FacePhotoStepView,
'id-photo-step': edx.verify_student.IDPhotoStepView,
'review-photos-step': edx.verify_student.ReviewPhotosStepView,
'enrollment-confirmation-step': edx.verify_student.EnrollmentConfirmationStepView
};
// Create the verification model, which is shared
// among the different steps. This allows
// one step to save photos and another step
// to submit them.
verificationModel = new edx.verify_student.VerificationModel();
for ( i = 0; i < this.displaySteps.length; i++ ) {
stepName = this.displaySteps[i].name;
subview = null;
if ( i < this.displaySteps.length - 1) {
nextStepTitle = this.displaySteps[i + 1].title;
} else {
nextStepTitle = "";
}
if ( subviewConstructors.hasOwnProperty( stepName ) ) {
stepData = {};
// Add any info specific to this step
if ( stepInfo.hasOwnProperty( stepName ) ) {
_.extend( stepData, stepInfo[ stepName ] );
}
subviewConfig = {
errorModel: this.errorModel,
templateUrl: this.displaySteps[i].templateUrl,
nextStepNum: (i + 2), // Next index, starting from 1
nextStepTitle: nextStepTitle,
stepData: stepData
};
// For photo verification steps, set the shared photo model
if ( this.VERIFICATION_VIEW_NAMES.indexOf(stepName) >= 0 ) {
_.extend( subviewConfig, { model: verificationModel } );
}
// Create the subview instance
// Note that we are NOT yet rendering the view,
// so this doesn't trigger GET requests or modify
// the DOM.
this.subviews[stepName] = new subviewConstructors[stepName]( subviewConfig );
// Listen for events to change the current step
this.listenTo( this.subviews[stepName], 'next-step', this.nextStep );
this.listenTo( this.subviews[stepName], 'go-to-step', this.goToStep );
}
}
},
render: function() {
this.progressView.render();
this.renderCurrentStep();
return this;
},
renderCurrentStep: function() {
var stepName, stepView, stepEl;
// Get or create the step container
stepEl = $("#current-step-container");
if (!stepEl.length) {
stepEl = $('<div id="current-step-container"></div>').appendTo(this.el);
}
// Render the subview
// Note that this will trigger a GET request for the
// underscore template.
// When the view is rendered, it will overwrite the existing
// step in the DOM.
stepName = this.displaySteps[ this.currentStepIndex ].name;
stepView = this.subviews[ stepName ];
stepView.el = stepEl;
stepView.render();
},
nextStep: function() {
this.currentStepIndex = Math.min( this.currentStepIndex + 1, this.displaySteps.length - 1 );
this.render();
},
goToStep: function( stepName ) {
var stepIndex = _.indexOf(
_.pluck( this.displaySteps, 'name' ),
stepName
);
if ( stepIndex >= 0 ) {
this.currentStepIndex = stepIndex;
this.render();
}
},
});
})(jQuery, _, Backbone, gettext);
/**
* View for the "payment confirmation" step of the payment/verification flow.
*/
var edx = edx || {};
(function( $ ) {
'use strict';
edx.verify_student = edx.verify_student || {};
// The "Verify Later" button goes directly to the dashboard,
// The "Verify Now" button reloads this page with the "skip-first-step"
// flag set. This allows the user to navigate back to the confirmation
// if he/she wants to.
// For this reason, we don't need any custom click handlers here.
edx.verify_student.PaymentConfirmationStepView = edx.verify_student.StepView.extend({});
})( jQuery );
/**
* Show progress steps in the payment/verification flow.
*/
var edx = edx || {};
(function( $, _, Backbone, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ProgressView = Backbone.View.extend({
template: '#progress-tpl',
initialize: function( obj ) {
this.displaySteps = obj.displaySteps || {};
this.currentStepIndex = obj.currentStepIndex || 0;
},
render: function() {
var renderedHtml, context;
context = {
steps: this.steps()
};
renderedHtml = _.template( $(this.template).html(), context );
$(this.el).html(renderedHtml);
},
steps: function() {
var i,
stepDescription,
steps = [];
for ( i = 0; i < this.displaySteps.length; i++ ) {
stepDescription = {
title: this.displaySteps[i].title,
isCurrent: (i === this.currentStepIndex ),
isComplete: (i < this.currentStepIndex )
};
steps.push(stepDescription);
}
return steps;
}
});
})( $, _, Backbone, gettext );
/**
* View for the "review photos" step of the payment/verification flow.
*/
var edx = edx || {};
(function( $, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.ReviewPhotosStepView = edx.verify_student.StepView.extend({
postRender: function() {
var model = this.model;
// Load the photos from the previous steps
$( "#face_image")[0].src = this.model.get('faceImage');
$( "#photo_id_image")[0].src = this.model.get('identificationImage');
// Prep the name change dropdown
$( '.expandable-area' ).slideUp();
$( '.is-expandable' ).addClass('is-ready');
$( '.is-expandable .title-expand' ).on( 'click', this.expandCallback );
// Disable the submit button until user confirmation
$( '#confirm_pics_good' ).on( 'click', this.toggleSubmitEnabled );
// Go back to the first photo step if we need to retake photos
$( '#retake_photos_button' ).on( 'click', _.bind( this.retakePhotos, this ) );
// When moving to the next step, submit photos for verification
$( '#next_step_button' ).on( 'click', _.bind( this.submitPhotos, this ) );
},
toggleSubmitEnabled: function() {
$( '#next_step_button' ).toggleClass( 'is-disabled' );
},
retakePhotos: function() {
this.goToStep( 'face-photo-step' );
},
submitPhotos: function() {
// Disable the submit button to prevent duplicate submissions
$( "#next_step_button" ).addClass( "is-disabled" );
// On success, move on to the next step
this.listenToOnce( this.model, 'sync', _.bind( this.nextStep, this ) );
// On failure, re-enable the submit button and display the error
this.listenToOnce( this.model, 'error', _.bind( this.handleSubmissionError, this ) );
// Submit
this.model.set( 'fullName', $( '#new-name' ).val() );
this.model.save();
},
handleSubmissionError: function( xhr ) {
// Re-enable the submit button to allow the user to retry
var isConfirmChecked = $( "#confirm_pics_good" ).prop('checked');
$( "#next_step_button" ).toggleClass( "is-disabled", !isConfirmChecked );
// Display the error
if ( xhr.status === 400 ) {
this.errorModel.set({
errorTitle: gettext( 'Could not submit photos' ),
errorMsg: xhr.responseText,
shown: true
});
}
else {
this.errorModel.set({
errorTitle: gettext( 'Could not submit photos' ),
errorMsg: gettext( 'An unexpected error occurred. Please try again later.' ),
shown: true
});
}
},
expandCallback: function(event) {
event.preventDefault();
$(this).next('.expandable-area' ).slideToggle();
var title = $( this ).parent();
title.toggleClass( 'is-expanded' );
title.attr( 'aria-expanded', !title.attr('aria-expanded') );
}
});
})( jQuery, gettext );
/**
* Base view for defining steps in the payment/verification flow.
*
* Each step view lazy-loads its underscore template.
* This reduces the size of the initial page, since we don't
* need to include the DOM structure for each step
* in the initial load.
*
* Step subclasses are responsible for defining a template
* and installing custom event handlers (including buttons
* to move to the next step).
*
* The superclass is responsible for downloading the underscore
* template and rendering it, using context received from
* the server (in data attributes on the initial page load).
*
*/
var edx = edx || {};
(function( $, _, _s, Backbone, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.StepView = Backbone.View.extend({
initialize: function( obj ) {
_.extend( this, obj );
/* Mix non-conflicting functions from underscore.string
* (all but include, contains, and reverse) into the
* Underscore namespace
*/
_.mixin( _s.exports() );
},
render: function() {
if ( !this.renderedHtml && this.templateUrl) {
$.ajax({
url: this.templateUrl,
type: 'GET',
context: this,
success: this.handleResponse,
error: this.handleError
});
} else {
$( this.el ).html( this.renderedHtml );
this.postRender();
}
},
handleResponse: function( data ) {
var context = {
nextStepNum: this.nextStepNum,
nextStepTitle: this.nextStepTitle
};
// Include step-specific information
_.extend( context, this.stepData );
this.renderedHtml = _.template( data, context );
$( this.el ).html( this.renderedHtml );
this.postRender();
},
handleError: function() {
this.errorModel.set({
errorTitle: gettext("Error"),
errorMsg: gettext("An unexpected error occurred. Please reload the page to try again."),
shown: true
});
},
postRender: function() {
// Sub-classes can override this method
// to install custom event handlers.
},
nextStep: function() {
this.trigger('next-step');
},
goToStep: function( stepName ) {
this.trigger( 'go-to-step', stepName );
}
});
})( jQuery, _, _.str, Backbone, gettext );
/**
* Interface for retrieving webcam photos.
* Supports both HTML5 and Flash.
*/
var edx = edx || {};
(function( $, _, Backbone, gettext ) {
'use strict';
edx.verify_student = edx.verify_student || {};
edx.verify_student.WebcamPhotoView = Backbone.View.extend({
template: "#webcam_photo-tpl",
videoCaptureBackend: {
html5: {
initialize: function( obj ) {
this.URL = (window.URL || window.webkitURL);
this.video = obj.video || "";
this.canvas = obj.canvas || "";
this.stream = null;
// Start the capture
this.getUserMediaFunc()(
{ video: true },
_.bind( this.getUserMediaCallback, this ),
_.bind( this.handleVideoFailure, this )
);
},
isSupported: function() {
return this.getUserMediaFunc() !== undefined;
},
snapshot: function() {
var video;
if ( this.stream ) {
video = this.getVideo();
this.getCanvas().getContext('2d').drawImage( video, 0, 0 );
video.pause();
return true;
}
return false;
},
getImageData: function() {
return this.getCanvas().toDataURL( 'image/png' );
},
reset: function() {
this.getVideo().play();
},
getUserMediaFunc: function() {
var userMedia = (
navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia
);
if ( userMedia ) {
return _.bind( userMedia, navigator );
}
},
getUserMediaCallback: function( stream ) {
var video = this.getVideo();
this.stream = stream;
video.src = this.URL.createObjectURL( stream );
video.play();
},
getVideo: function() {
return $( this.video ).first()[0];
},
getCanvas: function() {
return $( this.canvas ).first()[0];
},
handleVideoFailure: function() {
this.trigger(
'error',
gettext( 'Video capture error' ),
gettext( 'Please check that your webcam is connected and you have allowed access to your webcam.' )
);
}
},
flash: {
initialize: function( obj ) {
this.wrapper = obj.wrapper || "";
this.imageData = "";
// Replace the camera section with the flash object
$( this.wrapper ).html( this.flashObjectTag() );
// Wait for the player to load, then verify camera support
// Trigger an error if no camera is available.
this.checkCameraSupported();
},
isSupported: function() {
try {
var flashObj = new ActiveXObject('ShockwaveFlash.ShockwaveFlash');
if ( flashObj ) {
return true;
}
} catch(ex) {
if ( navigator.mimeTypes["application/x-shockwave-flash"] !== undefined ) {
return true;
}
}
return false;
},
snapshot: function() {
var flashObj = this.getFlashObject();
if ( flashObj.cameraAuthorized() ) {
this.imageData = flashObj.snap();
return true;
}
return false;
},
reset: function() {
this.getFlashObject().reset();
},
getImageData: function() {
return this.imageData;
},
flashObjectTag: function() {
return (
'<object type="application/x-shockwave-flash" ' +
'id="flash_video" ' +
'name="flash_video" ' +
'data="/static/js/verify_student/CameraCapture.swf" ' +
'width="500" ' +
'height="375">' +
'<param name="quality" value="high">' +
'<param name="allowscriptaccess" value="sameDomain">' +
'</object>'
);
},
getFlashObject: function() {
return $( "#flash_video" )[0];
},
checkCameraSupported: function() {
var flashObj = this.getFlashObject(),
isLoaded = false,
hasCamera = false;
isLoaded = (
flashObj &&
flashObj.hasOwnProperty( 'percentLoaded' ) &&
flashObj.percentLoaded() === 100
);
// On some browsers, the flash object will say it has a camera
// even "percentLoaded" isn't defined.
hasCamera = (
flashObj &&
flashObj.hasOwnProperty( 'hasCamera' ) &&
flashObj.hasCamera()
);
// If we've fully loaded, and no camera is available,
// then show an error.
if ( isLoaded && !hasCamera ) {
this.trigger(
'error',
gettext( "No Webcam Detected" ),
gettext( "You don't seem to have a webcam connected." ) + " " +
gettext( "Double-check that your webcam is connected and working to continue.")
);
}
// If we're still waiting for the player to load, check
// back later.
else if ( !isLoaded && !hasCamera ) {
setTimeout( _.bind( this.checkCameraSupported, this ), 50 );
}
// Otherwise, the flash player says it has a camera,
// so we don't need to keep checking.
}
}
},
videoBackendPriority: ['html5', 'flash'],
initialize: function( obj ) {
this.submitButton = obj.submitButton || "";
this.modelAttribute = obj.modelAttribute || "";
this.errorModel = obj.errorModel || {};
this.backend = this.chooseVideoCaptureBackend();
if ( !this.backend ) {
this.handleError(
gettext( "No Flash Detected" ),
gettext( "You don't seem to have Flash installed." ) + " " +
_.sprintf(
gettext( "%(a_start)s Get Flash %(a_end)s to continue your enrollment." ),
{
a_start: '<a rel="external" href="http://get.adobe.com/flashplayer/">',
a_end: '</a>'
}
)
);
}
else {
_.extend( this.backend, Backbone.Events );
this.listenTo( this.backend, 'error', this.handleError );
}
},
render: function() {
var renderedHtml;
// Load the template for the webcam into the DOM
renderedHtml = _.template( $( this.template ).html(), {} );
$( this.el ).html( renderedHtml );
// Initialize the video capture backend
// We need to do this after rendering the template
// so that the backend has the opportunity to modify the DOM.
this.backend.initialize({
wrapper: "#camera",
video: '#photo_id_video',
canvas: '#photo_id_canvas'
});
// Install event handlers
$( "#webcam_reset_button", this.el ).on( 'click', _.bind( this.reset, this ) );
$( "#webcam_capture_button", this.el ).on( 'click', _.bind( this.capture, this ) );
$( "#webcam_approve_button", this.el ).on( 'click', _.bind( this.approve, this ) );
return this;
},
reset: function() {
// Disable the submit button
$( this.submitButton ).addClass( "is-disabled" );
// Reset the video capture
this.backend.reset();
// Go back to the initial button state
$( "#webcam_reset_button", this.el ).hide();
$( "#webcam_approve_button", this.el ).removeClass( "approved" ).hide();
$( "#webcam_capture_button", this.el ).show();
},
capture: function() {
// Take a snapshot of the video
var success = this.backend.snapshot();
// Show the reset and approve buttons
if ( success ) {
$( "#webcam_capture_button", this.el ).hide();
$( "#webcam_reset_button", this.el ).show();
$( "#webcam_approve_button", this.el ).show();
}
},
approve: function() {
// Save the data to the model
this.model.set( this.modelAttribute, this.backend.getImageData() );
// Make the "approve" button green
$( "#webcam_approve_button" ).addClass( "approved" );
// Enable the submit button
$( this.submitButton ).removeClass( "is-disabled" );
},
chooseVideoCaptureBackend: function() {
var i, backendName, backend;
for ( i = 0; i < this.videoBackendPriority.length; i++ ) {
backendName = this.videoBackendPriority[i];
backend = this.videoCaptureBackend[backendName];
if ( backend.isSupported() ) {
return backend;
}
}
},
handleError: function( errorTitle, errorMsg ) {
// Hide the buttons
$( "#webcam_capture_button", this.el ).hide();
$( "#webcam_reset_button", this.el ).hide();
$( "#webcam_approve_button", this.el ).hide();
// Show the error message
this.errorModel.set({
errorTitle: errorTitle,
errorMsg: errorMsg,
shown: true
});
}
});
})( jQuery, _, Backbone, gettext );
<div id="error" class="wrapper-msg wrapper-msg-activate">
<div class=" msg msg-activate">
<i class="msg-icon icon-warning-sign"></i>
<div class="msg-content">
<h3 class="title"><%- errorTitle %></h3>
<div class="copy">
<p><%- errorMsg %></p>
</div>
</div>
</div>
</div>
<div id="wrapper-facephoto" class="wrapper-view block-photo">
<div class="facephoto view">
<h3 class="title"><%- gettext( "Take Your Photo" ) %></h3>
<div class="instruction">
<p><%- gettext( "Use your webcam to take a picture of your face so we can match it with the picture on your ID." ) %></p>
</div>
<div class="wrapper-task">
<div id="facecam" class="task cam"></div>
<div class="wrapper-help">
<div class="help help-task photo-tips facetips">
<h4 class="title"><%- gettext( "Tips on taking a successful photo" ) %></h4>
<div class="copy">
<ul class="list-help">
<li class="help-item"><%- gettext( "Make sure your face is well-lit" ) %></li>
<li class="help-item"><%- gettext( "Be sure your entire face is inside the frame" ) %></li>
<li class="help-item"><%- gettext( "Can we match the photo you took with the one on your ID?" ) %></li>
<li class="help-item"><%- gettext( "Once in position, use the camera button" ) %> <span class="example">(<i class="icon-camera"></i>)</span> <%- gettext( "to capture your picture" ) %></li>
<li class="help-item"><%- gettext( "Use the checkmark button" ) %> <span class="example">(<i class="icon-ok"></i>)</span> <%- gettext( "once you are happy with the photo" ) %></li>
</ul>
</div>
</div>
<div class="help help-faq facefaq">
<h4 class="sr title"><%- gettext( "Common Questions" ) %></h4>
<div class="copy">
<dl class="list-faq">
<dt class="faq-question"><%- gettext( "Why do you need my photo?" ) %></dt>
<dd class="faq-answer"><%- gettext( "As part of the verification process, we need your photo to confirm that you are you." ) %></dd>
<dt class="faq-question"><%- gettext( "What do you do with this picture?" ) %></dt>
<dd class="faq-answer"><%- gettext( "We only use it to verify your identity. It is not displayed anywhere." ) %></dd>
</dl>
</div>
</div>
</div>
</div>
<% if ( nextStepTitle ) { %>
<nav class="nav-wizard" id="face_next_button_nav">
<span class="help help-inline">
<%- _.sprintf( gettext( "Once you verify your photo looks good, you can move on to step %s." ), nextStepNum ) %>
</span>
<ol class="wizard-steps">
<li class="wizard-step">
<a id="next_step_button" class="next action-primary is-disabled" aria-hidden="true" title="Next">
<%- _.sprintf( gettext( "Go to Step %s" ), nextStepNum ) %>: <%- nextStepTitle %>
</a>
</li>
</ol>
</nav>
<% } %>
</div>
</div>
<div id="wrapper-idphoto" class="wrapper-view block-photo">
<div class="idphoto view">
<h3 class="title"><%- gettext( "Show Us Your ID" ) %></h3>
<div class="instruction">
<p><%- gettext("Use your webcam to take a picture of your ID so we can match it with your photo and the name on your account.") %></p>
</div>
<div class="wrapper-task">
<div id="idcam" class="task cam"></div>
<div class="wrapper-help">
<div class="help help-task photo-tips idtips">
<h4 class="title"><%- gettext( "Tips on taking a successful photo" ) %></h4>
<div class="copy">
<ul class="list-help">
<li class="help-item"><%- gettext( "Make sure your ID is well-lit" ) %></li>
<li class="help-item"><%- gettext( "Check that there isn't any glare" ) %></li>
<li class="help-item"><%- gettext( "Ensure that you can see your photo and read your name" ) %></li>
<li class="help-item"><%- gettext( "Try to keep your fingers at the edge to avoid covering important information" ) %></li>
<li class="help-item"><%- gettext( "Acceptable IDs include drivers licenses, passports, or other goverment-issued IDs that include your name and photo" ) %></li>
<li class="help-item"><%- gettext( "Once in position, use the camera button ") %> <span class="example">(<i class="icon-camera"></i>)</span> <%- gettext( "to capture your ID" ) %></li>
<li class="help-item"><%- gettext( "Use the checkmark button" ) %> <span class="example">(<i class="icon-ok"></i>)</span> <%- gettext( "once you are happy with the photo" ) %></li>
</ul>
</div>
</div>
<div class="help help-faq facefaq">
<h4 class="sr title"><%- gettext( "Common Questions" ) %></h4>
<div class="copy">
<dl class="list-faq">
<dt class="faq-question"><%- gettext( "Why do you need a photo of my ID?" ) %></dt>
<dd class="faq-answer"><%- gettext( "We need to match your ID with your photo and name to confirm that you are you." ) %></dd>
<dt class="faq-question"><%- gettext( "What do you do with this picture?" ) %></dt>
<dd class="faq-answer"><%- gettext( "We encrypt it and send it to our secure authorization service for review. We use the highest levels of security and do not save the photo or information anywhere once the match has been completed." ) %></dd>
</dl>
</div>
</div>
</div>
</div>
<% if ( nextStepTitle ) { %>
<nav class="nav-wizard" id="face_next_button_nav">
<span class="help help-inline">
<%- _.sprintf( gettext( "Once you verify your photo looks good, you can move on to step %s." ), nextStepNum ) %>
</span>
<ol class="wizard-steps">
<li class="wizard-step">
<a id="next_step_button" class="next action-primary is-disabled" aria-hidden="true" title="Next">
<%- _.sprintf( gettext( "Go to Step %s" ), nextStepNum ) %>: <%- nextStepTitle %>
</a>
</li>
</ol>
</nav>
<% } %>
</div>
</div>
<div class="wrapper-content-main">
<article class="content-main">
<h3 class="title"><%- introTitle %></h3>
<div class="instruction"><p><%- introMsg %></p></div>
<ul class="list-reqs">
<% if ( requirements['photo-id-required'] ) { %>
<li class="req req-1 req-id">
<h4 class="title"><%- gettext( "Identification" ) %></h4>
<div class="placeholder-art">
<i class="icon-list-alt icon-under"></i>
<i class="icon-user icon-over"></i>
</div>
<div class="copy">
<p>
<span class="copy-super"><%- gettext( "A photo identification document" ) %></span>
<span class="copy-sub"><%- gettext( "A driver's license, passport, or other government or school-issued ID with your name and picture on it." ) %></span>
</p>
</div>
</li>
<% } %>
<% if ( requirements['webcam-required']) { %>
<li class="req req-2 req-webcam">
<h4 class="title"><%- gettext( "Webcam" ) %></h4>
<div class="placeholder-art">
<i class="icon-facetime-video"></i>
</div>
<div class="copy">
<p>
<span class="copy-super"><%- gettext( "A webcam and a modern browser" ) %></span>
<span class="copy-sub"><strong>
<a rel="external" href="https://www.mozilla.org/en-US/firefox/new/">Firefox</a>,
<a rel="external" href="https://www.google.com/intl/en/chrome/browser/">Chrome</a>,
<a rel="external" href="http://www.apple.com/safari/">Safari</a>,
<a rel="external" href="http://windows.microsoft.com/en-us/internet-explorer/download-ie"><%- gettext("Internet Explorer 9 or later" ) %></a></strong>.
<%- gettext( "Please make sure your browser is updated to the most recent version possible." ) %>
</span>
</p>
</div>
</li>
<% } %>
<% if ( requirements['credit-card-required'] ) { %>
<li class="req req-3 req-payment">
<h4 class="title"><%- gettext( "Credit or Debit Card" ) %></h4>
<div class="placeholder-art">
<i class="icon-credit-card"></i>
</div>
<div class="copy">
<p>
<span class="copy-super"><%- gettext( "A major credit or debit card" ) %></span>
<span class="copy-sub"><%- gettext( "Visa, MasterCard, American Express, Discover, Diners Club, or JCB with the Discover logo." ) %></span>
</p>
</div>
</li>
<% } %>
</ul>
<% if ( nextStepTitle ) { %>
<nav class="nav-wizard is-ready">
<ol class="wizard-steps">
<li class="wizard-step">
<a class="next action-primary" id="next_step_button" href="?skip-first-step=1">
<%- _.sprintf( gettext( "Go to Step %s" ), nextStepNum ) %>: <%- nextStepTitle %>
</a>
</li>
</ol>
</nav>
<% } %>
</article>
</div>
<div id="wrapper-review" class="wrapper-view">
<div class="review view">
<h3 class="title"><%- gettext( "Make Payment" ) %></h3>
<div class="instruction">
<p><%- gettext( "Make payment. TODO: actual copy here." ) %></p>
</div>
<div class="wrapper-task">
<ol class="review-tasks">
<% if ( suggestedPrices.length > 0 ) { %>
<li class="review-task review-task-contribution">
<h4 class="title"><%- gettext( "Enter Your Contribution Level" ) %></h4>
<div class="copy">
<p><%- _.sprintf(
gettext( "Please confirm your contribution for this course (min. $ %(minPrice)s %(currency)s)" ),
{ minPrice: minPrice, currency: currency }
) %>
</p>
</div>
<ul class="list-fields contribution-options">
<% for ( var i = 0; i < suggestedPrices.length; i++ ) {
price = suggestedPrices[i];
%>
<li class="field contribution-option">
<input type="radio" name="contribution" value="<%- price %>" id="contribution-<%- price %>" />
<label for="contribution-<%- price %>">
<span class="deco-denomination">$</span>
<span class="label-value"><%- price %></span>
<span class="denomination-name"><%- currency %></span>
</label>
</li>
<% } %>
<li class="field contribution-option">
<ul class="field-group field-group-other">
<li class="contribution-option contribution-option-other1">
<input type="radio" id="contribution-other" name="contribution" value="" />
<label for="contribution-other"><span class="sr">Other</span></label>
</li>
<li class="contribution-option contribution-option-other2">
<label for="contribution-other-amt">
<span class="sr">Other Amount</span>
</label>
<div class="wrapper">
<span class="deco-denomination">$</span>
<input type="text" size="9" name="contribution-other-amt" id="contribution-other-amt" value=""/>
<span class="denomination-name"><%- currency %></span>
</div>
</li>
</ul>
</li>
</ul>
</li>
<% } else {%>
<li class="review-task review-task-contribution">
<h4 class="title"><%- gettext( "Your Course Total" ) %></h4>
<div class="copy">
<p><%- gettext( "To complete your registration, you will need to pay:" ) %></p>
</div>
<ul class="list-fields contribution-options">
<li class="field contribution-option">
<span class="deco-denomination">$</span>
<span class="label-value"><%- minPrice %></span>
<span class="denomination-name"><%- currency %></span>
</li>
</ul>
</li>
<% } %>
</ol>
</div>
</div>
<nav class="nav-wizard is-ready">
<ol class="wizard-steps">
<li class="wizard-step">
<a class="next action-primary is-disabled" id="pay_button">
<%- gettext( "Go to payment" ) %>
</a>
</li>
</ol>
</nav>
<form id="payment-processor-form"></form>
</div>
<%!
import json
from django.utils.translation import ugettext as _
from verify_student.views import PayAndVerifyView
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="bodyclass">register verification-process step-requirements</%block>
<%block name="pagetitle">${messages.page_title}</%block>
<%block name="header_extras">
% for template_name in ["progress", "webcam_photo", "error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="verify_student/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="js_extra">
<%static:js group='rwd_header_footer'/>
<script src="${static.url('js/vendor/underscore-min.js')}"></script>
<script src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
<%static:js group='verify_student'/>
</%block>
<%block name="content">
## Top-level wrapper for errors
## JavaScript views may append to this wrapper
<div id="error-container" style="display: none;"></div>
<div class="container">
<section class="wrapper carousel">
## Verification status header
<header class="page-header">
<h2 class="title">
<span class="wrapper-sts">
<span class="sts-label">${messages.top_level_msg}</span>
</span>
<span class="sts-track ${"professional-ed" if course_mode.slug == "professional" else ""}">
<span class="sts-track-value"><span class="context">${messages.status_msg}</span>: ${course_mode.name}<span>
</span>
</h2>
</header>
## Payment / Verification flow
## Most of these data attributes are used to dynamically render
## the steps, but some are just useful for A/B test setup.
<div
id="pay-and-verify-container"
data-full-name='${user_full_name}'
data-platform-name='${platform_name}'
data-course-key='${course_key}'
data-course-name='${course.display_name}'
data-course-start-date='${course.start_datetime_text()}'
data-courseware-url='${courseware_url}'
data-course-mode-name='${course_mode.name}'
data-course-mode-slug='${course_mode.slug}'
data-course-mode-min-price='${course_mode.min_price}'
data-course-mode-suggested-prices='${course_mode.suggested_prices}'
data-course-mode-currency='${course_mode.currency}'
data-purchase-endpoint='${purchase_endpoint}'
data-display-steps='${json.dumps(display_steps)}'
data-current-step='${current_step}'
data-requirements='${json.dumps(requirements)}'
data-msg-key='${message_key}'
data-intro-title='${messages.intro_title}'
data-intro-msg='${messages.intro_msg}'
></div>
## Support
<div class="wrapper-content-supplementary">
<aside class="content-supplementary">
<ul class="list-help">
<li class="help-item help-item-questions">
<h3 class="title">${_("Have questions?")}</h3>
<div class="copy">
<p>${_("Please read {a_start}our FAQs to view common questions about our certificates{a_end}.").format(a_start='<a rel="external" href="'+ marketing_link('WHAT_IS_VERIFIED_CERT') + '">', a_end="</a>")}</p>
</div>
</li>
% if PayAndVerifyView.WEBCAM_REQ in requirements:
<li class="help-item help-item-technical">
<h3 class="title">${_("Technical Requirements")}</h3>
<div class="copy">
<p>${_("Please make sure your browser is updated to the {a_start}most recent version possible{a_end}. Also, please make sure your <strong>webcam is plugged in, turned on, and allowed to function in your web browser (commonly adjustable in your browser settings).</strong>").format(a_start='<strong><a rel="external" href="http://browsehappy.com/">', a_end="</a></strong>")}</p>
</div>
</li>
% endif
</ul>
</aside>
</div>
</section>
</div>
</%block>
<div class="wrapper-content-main">
<article class="content-main">
<h3 class="title"><%- gettext( "Congratulations! You are now enrolled in the verified track." ) %></h3>
<div class="instruction">
<p><%- gettext( "You are now enrolled as a verified student! Your enrollment details are below.") %></p>
</div>
<ul class="list-info">
<li class="info-item course-info">
<h4 class="title">
<%- gettext( "You are enrolled in " ) %> :
</h4>
<div class="wrapper-report">
<table class="report report-course">
<caption class="sr"><%- gettext( "A list of courses you have just enrolled in as a verified student" ) %></caption>
<thead>
<tr>
<th scope="col" ><%- gettext( "Course" ) %></th>
<th scope="col" ><%- gettext( "Status" ) %></th>
<th scope="col" ><span class="sr"><%- gettext( "Options" ) %></span></th>
</tr>
</thead>
<tbody>
<tr>
<td><%- courseName %></td>
<td>
<%- _.sprintf( gettext( "Starts: %(start)s" ), { start: courseStartDate } ) %>
</td>
<td class="options">
<% if ( coursewareUrl ) { %>
<a class="action action-course" href="<%- coursewareUrl %>"><%- gettext( "Go to Course" ) %></a>
<% } %>
</td>
</tr>
</tbody>
<tfoot>
<tr class="course-actions">
<td colspan="3">
<a class="action action-dashboard" href="/dashboard"><%- gettext("Go to your dashboard") %></a>
</td>
</tr>
</tfoot>
</table>
</div>
</li>
</ul>
<% if ( nextStepTitle ) { %>
<nav class="nav-wizard is-ready">
<ol class="wizard-steps">
<li class="wizard-step">
<a class="next action-primary" id="verify_now_button" href="?skip-first-step=1">
<%- gettext( "Verify Now" ) %>
</a>
</li>
<li class="wizard-step">
<a class="next action-secondary" id="verify_later_button" href="/dashboard">
<%- gettext( "Verify Later" ) %>
</a>
</li>
</ol>
</nav>
<% } %>
</article>
</div>
<div class="wrapper-progress">
<section class="progress">
<h3 class="sr title"><%- gettext("Your Progress") %></h3>
<ol class="progress-steps">
<% for ( var stepNum = 0; stepNum < steps.length; stepNum++ ) { %>
<li
class="progress-step
<% if ( steps[stepNum].isCurrent ) { %> is-current <% } %>
<% if ( steps[stepNum].isComplete ) { %> is-completed <% } %>"
id="progress-step-<%- stepNum + 1 %>"
>
<span class="wrapper-step-number"><span class="step-number"><%- stepNum + 1 %></span></span>
<span class="step-name"><span class="sr"><%- gettext("Current Step") %>: </span><%- steps[stepNum].title %></span>
</li>
<% } %>
<span class="progress-sts">
<span class="progress-sts-value"></span>
</span>
</section>
</div>
<div id="wrapper-review" class="wrapper-view">
<div class="review view">
<h3 class="title"><%- gettext( "Verify Your Submission" ) %></h3>
<div class="instruction">
<p><%- gettext( "Make sure we can verify your identity with the photos and information below." ) %></p>
</div>
<div class="wrapper-task">
<ol class="review-tasks">
<li class="review-task review-task-photos">
<h4 class="title"><%- gettext( "Review the Photos You've Taken" ) %></h4>
<div class="copy">
<p><%- gettext( "Please review the photos and verify that they meet the requirements listed below." ) %></p>
</div>
<ol class="wrapper-photos">
<li class="wrapper-photo">
<div class="placeholder-photo">
<img id="face_image" src=""/>
</div>
<div class="help-tips">
<h5 class="title"><%- gettext( "The photo above needs to meet the following requirements:" ) %></h5>
<ul class="list-help list-tips copy">
<li class="tip"><%- gettext( "Be well lit" ) %></li>
<li class="tip"><%- gettext( "Show your whole face" ) %></li>
<li class="tip"><%- gettext( "The photo on your ID must match the photo of your face" ) %></li>
</ul>
</div>
</li>
<li class="wrapper-photo">
<div class="placeholder-photo">
<img id="photo_id_image" src=""/>
</div>
<div class="help-tips">
<h5 class="title"><%- gettext( "The photo above needs to meet the following requirements:" ) %></h5>
<ul class="list-help list-tips copy">
<li class="tip"><%- gettext( "Be readable (not too far away, no glare)" ) %></li>
<li class="tip"><%- gettext( "The photo on your ID must match the photo of your face" ) %></li>
<li class="tip"><%- gettext( "The name on your ID must match the name on your account below" ) %></li>
</ul>
</div>
</li>
</ol>
<div class="msg msg-retake msg-followup">
<div class="copy">
<p><%- gettext( "Photos don't meet the requirements?" ) %></p>
</div>
<ul class="list-actions">
<li class="action action-retakephotos">
<a id="retake_photos_button" class="retake-photos">
<%- gettext( "Retake Your Photos" ) %>
</a>
</li>
</ul>
</div>
</li>
<li class="review-task review-task-name">
<h4 class="title"><%- gettext( "Check Your Name" ) %></h4>
<div class="copy">
<p><%- _.sprintf( gettext( "Make sure your full name on your %(platformName)s account (%(fullName)s) matches your ID. We will also use this as the name on your certificate." ), { platformName: platformName, fullName: fullName } ) %></p>
</div>
<div class="msg msg-followup">
<div class="help-tip is-expandable">
<h5 class="title title-expand" aria-expanded="false" role="link">
<i class="icon-caret-down expandable-icon"></i>
<%- gettext( "What if the name on my account doesn't match the name on my ID?" ) %>
</h5>
<div class="copy expandable-area">
<p><%- gettext( "You should change the name on your account to match." ) %></p>
<input type="text" name="new-name" id="new-name" placeholder="New name">
</div>
</div>
</div>
</ol>
</div>
<nav class="nav-wizard">
<div class="prompt-verify">
<h3 class="title"><%- gettext( "Before proceeding, please confirm that your details match" ) %></h3>
<p class="copy">
<%- _.sprintf( gettext( "Once you verify your details match the requirements, you can move on to step %(stepNum)s, %(stepTitle)s." ), { stepNum: nextStepNum, stepTitle: nextStepTitle } ) %>
</p>
<ul class="list-actions">
<li class="action action-verify">
<input type="checkbox" name="match" id="confirm_pics_good" />
<label for="confirm_pics_good"><%- gettext( "Yes! My details all match." ) %></label>
</li>
</ul>
</div>
<ol class="wizard-steps">
<li class="wizard-step step-proceed">
<a id="next_step_button" class="next action-primary is-disabled" aria-hidden="true" title="Next">
<%- _.sprintf( gettext( "Go to Step %s" ), nextStepNum ) %>: <%- nextStepTitle %>
</a>
</li>
</ol>
</nav>
</div>
</div>
<div class="placeholder-cam" id="camera">
<div class="placeholder-art">
<p class="copy"><%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %></p>
</div>
<video id="photo_id_video" autoplay></video><br/>
<canvas id="photo_id_canvas" style="display:none;" width="640" height="480"></canvas>
</div>
<div class="controls photo-controls">
<ul class="list-controls">
<li class="control control-redo" id="webcam_reset_button" style="display: none;">
<a class="action action-redo">
<i class="icon-undo"></i> <span class="sr"><%- gettext( "Retake" ) %></span>
</a>
</li>
<li class="control control-do" id="webcam_capture_button">
<a class="action action-do">
<i class="icon-camera"></i> <span class="sr"><%- gettext( "Take photo" ) %></span>
</a>
</li>
<li class="control control-approve" id="webcam_approve_button" style="display: none;">
<a class="action action-approve">
<i class="icon-ok"></i> <span class="sr"><%- gettext( "Looks good" ) %></span>
</a>
</li>
</ul>
</div>
......@@ -51,6 +51,7 @@ class ProfileInternalError(Exception):
FULL_NAME_MAX_LENGTH = 255
FULL_NAME_MIN_LENGTH = 2
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
......@@ -113,7 +114,7 @@ def update_profile(username, full_name=None):
if full_name is not None:
name_length = len(full_name)
if name_length > FULL_NAME_MAX_LENGTH or name_length == 0:
if name_length > FULL_NAME_MAX_LENGTH or name_length < FULL_NAME_MIN_LENGTH:
raise ProfileInvalidField("full_name", full_name)
else:
profile.update_name(full_name)
......
......@@ -47,7 +47,7 @@ class ProfileApiTest(TestCase):
self.assertEqual(profile['full_name'], u'ȻħȺɍłɇs')
@raises(profile_api.ProfileInvalidField)
@ddt.data('', 'a' * profile_api.FULL_NAME_MAX_LENGTH + 'a')
@ddt.data('', 'a', 'a' * profile_api.FULL_NAME_MAX_LENGTH + 'a')
def test_update_full_name_invalid(self, invalid_name):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
profile_api.update_profile(self.USERNAME, full_name=invalid_name)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment