diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py
index d1be88a4ac..8c865fe724 100644
--- a/lms/djangoapps/verify_student/tests/test_views.py
+++ b/lms/djangoapps/verify_student/tests/test_views.py
@@ -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)
diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py
index 9ee8e939bf..d3470ee2b1 100644
--- a/lms/djangoapps/verify_student/urls.py
+++ b/lms/djangoapps/verify_student/urls.py
@@ -1,6 +1,7 @@
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
+ }
+ ),
+ )
diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py
index b59d425153..be9f0f218f 100644
--- a/lms/djangoapps/verify_student/views.py
+++ b/lms/djangoapps/verify_student/views.py
@@ -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]
@@ -245,6 +834,63 @@ def create_order(request):
return HttpResponse(json.dumps(params), content_type="text/json")
+@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):
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 9ed7ecd771..c2274def34 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -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
diff --git a/lms/static/js/dashboard/donation.js b/lms/static/js/dashboard/donation.js
index 347865c71a..3246639823 100644
--- a/lms/static/js/dashboard/donation.js
+++ b/lms/static/js/dashboard/donation.js
@@ -241,4 +241,4 @@ var edx = edx || {};
});
});
-})(jQuery);
\ No newline at end of file
+})(jQuery);
diff --git a/lms/static/js/verify_student/models/verification_model.js b/lms/static/js/verify_student/models/verification_model.js
new file mode 100644
index 0000000000..ab542afec2
--- /dev/null
+++ b/lms/static/js/verify_student/models/verification_model.js
@@ -0,0 +1,54 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/pay_and_verify.js b/lms/static/js/verify_student/pay_and_verify.js
new file mode 100644
index 0000000000..88a1d2aeab
--- /dev/null
+++ b/lms/static/js/verify_student/pay_and_verify.js
@@ -0,0 +1,61 @@
+/**
+ * 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);
diff --git a/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js
new file mode 100644
index 0000000000..e865e30fca
--- /dev/null
+++ b/lms/static/js/verify_student/views/enrollment_confirmation_step_view.js
@@ -0,0 +1,16 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/error_view.js b/lms/static/js/verify_student/views/error_view.js
new file mode 100644
index 0000000000..861209628f
--- /dev/null
+++ b/lms/static/js/verify_student/views/error_view.js
@@ -0,0 +1,44 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/face_photo_step_view.js b/lms/static/js/verify_student/views/face_photo_step_view.js
new file mode 100644
index 0000000000..163387cf18
--- /dev/null
+++ b/lms/static/js/verify_student/views/face_photo_step_view.js
@@ -0,0 +1,26 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/id_photo_step_view.js b/lms/static/js/verify_student/views/id_photo_step_view.js
new file mode 100644
index 0000000000..77ec5cc6e2
--- /dev/null
+++ b/lms/static/js/verify_student/views/id_photo_step_view.js
@@ -0,0 +1,26 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/intro_step_view.js b/lms/static/js/verify_student/views/intro_step_view.js
new file mode 100644
index 0000000000..747c91ee50
--- /dev/null
+++ b/lms/static/js/verify_student/views/intro_step_view.js
@@ -0,0 +1,19 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/make_payment_step_view.js b/lms/static/js/verify_student/views/make_payment_step_view.js
new file mode 100644
index 0000000000..c4dc01f26d
--- /dev/null
+++ b/lms/static/js/verify_student/views/make_payment_step_view.js
@@ -0,0 +1,106 @@
+/**
+ * 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 ) {
+ $("").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 );
diff --git a/lms/static/js/verify_student/views/pay_and_verify_view.js b/lms/static/js/verify_student/views/pay_and_verify_view.js
new file mode 100644
index 0000000000..3baea6c107
--- /dev/null
+++ b/lms/static/js/verify_student/views/pay_and_verify_view.js
@@ -0,0 +1,168 @@
+/**
+ * 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 = $('
').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);
diff --git a/lms/static/js/verify_student/views/payment_confirmation_step_view.js b/lms/static/js/verify_student/views/payment_confirmation_step_view.js
new file mode 100644
index 0000000000..19a7e9a9c3
--- /dev/null
+++ b/lms/static/js/verify_student/views/payment_confirmation_step_view.js
@@ -0,0 +1,18 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/progress_view.js b/lms/static/js/verify_student/views/progress_view.js
new file mode 100644
index 0000000000..c78659f880
--- /dev/null
+++ b/lms/static/js/verify_student/views/progress_view.js
@@ -0,0 +1,49 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/review_photos_step_view.js b/lms/static/js/verify_student/views/review_photos_step_view.js
new file mode 100644
index 0000000000..ebd9d575dd
--- /dev/null
+++ b/lms/static/js/verify_student/views/review_photos_step_view.js
@@ -0,0 +1,91 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/step_view.js b/lms/static/js/verify_student/views/step_view.js
new file mode 100644
index 0000000000..b856ee47df
--- /dev/null
+++ b/lms/static/js/verify_student/views/step_view.js
@@ -0,0 +1,90 @@
+/**
+ * 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 );
diff --git a/lms/static/js/verify_student/views/webcam_photo_view.js b/lms/static/js/verify_student/views/webcam_photo_view.js
new file mode 100644
index 0000000000..0926836c5c
--- /dev/null
+++ b/lms/static/js/verify_student/views/webcam_photo_view.js
@@ -0,0 +1,312 @@
+/**
+ * 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 (
+ ''
+ );
+ },
+
+ 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_end: ''
+ }
+ )
+ );
+ }
+ 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 );
diff --git a/lms/templates/verify_student/enrollment_confirmation_step.underscore b/lms/templates/verify_student/enrollment_confirmation_step.underscore
new file mode 100644
index 0000000000..4b2ff48c7c
--- /dev/null
+++ b/lms/templates/verify_student/enrollment_confirmation_step.underscore
@@ -0,0 +1 @@
+
Enrollment confirmation!
diff --git a/lms/templates/verify_student/error.underscore b/lms/templates/verify_student/error.underscore
new file mode 100644
index 0000000000..eb4b34ff7c
--- /dev/null
+++ b/lms/templates/verify_student/error.underscore
@@ -0,0 +1,11 @@
+
+
+
+
+
<%- errorTitle %>
+
+
<%- errorMsg %>
+
+
+
+
diff --git a/lms/templates/verify_student/face_photo_step.underscore b/lms/templates/verify_student/face_photo_step.underscore
new file mode 100644
index 0000000000..4992b5dad6
--- /dev/null
+++ b/lms/templates/verify_student/face_photo_step.underscore
@@ -0,0 +1,58 @@
+
+
+
<%- gettext( "Take Your Photo" ) %>
+
+
<%- gettext( "Use your webcam to take a picture of your face so we can match it with the picture on your ID." ) %>
+
+
+
+
+
+
+
+
<%- gettext( "Tips on taking a successful photo" ) %>
+
+
+
+
<%- gettext( "Make sure your face is well-lit" ) %>
+
<%- gettext( "Be sure your entire face is inside the frame" ) %>
+
<%- gettext( "Can we match the photo you took with the one on your ID?" ) %>
+
<%- gettext( "Once in position, use the camera button" ) %> () <%- gettext( "to capture your picture" ) %>
+
<%- gettext( "Use the checkmark button" ) %> () <%- gettext( "once you are happy with the photo" ) %>
+
+
+
+
+
+
<%- gettext( "Common Questions" ) %>
+
+
+
+
<%- gettext( "Why do you need my photo?" ) %>
+
<%- gettext( "As part of the verification process, we need your photo to confirm that you are you." ) %>
+
<%- gettext( "What do you do with this picture?" ) %>
+
<%- gettext( "We only use it to verify your identity. It is not displayed anywhere." ) %>
+
+
+
+
+
+
+ <% if ( nextStepTitle ) { %>
+
+ <% } %>
+
+
+
diff --git a/lms/templates/verify_student/id_photo_step.underscore b/lms/templates/verify_student/id_photo_step.underscore
new file mode 100644
index 0000000000..f5a1ced871
--- /dev/null
+++ b/lms/templates/verify_student/id_photo_step.underscore
@@ -0,0 +1,60 @@
+
+
+
<%- gettext( "Show Us Your ID" ) %>
+
+
<%- 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.") %>
+
+
+
+
+
+
+
+
<%- gettext( "Tips on taking a successful photo" ) %>
+
+
+
+
<%- gettext( "Make sure your ID is well-lit" ) %>
+
<%- gettext( "Check that there isn't any glare" ) %>
+
<%- gettext( "Ensure that you can see your photo and read your name" ) %>
+
<%- gettext( "Try to keep your fingers at the edge to avoid covering important information" ) %>
+
<%- gettext( "Acceptable IDs include drivers licenses, passports, or other goverment-issued IDs that include your name and photo" ) %>
+
<%- gettext( "Once in position, use the camera button ") %> () <%- gettext( "to capture your ID" ) %>
+
<%- gettext( "Use the checkmark button" ) %> () <%- gettext( "once you are happy with the photo" ) %>
+
+
+
+
+
+
<%- gettext( "Common Questions" ) %>
+
+
+
+
<%- gettext( "Why do you need a photo of my ID?" ) %>
+
<%- gettext( "We need to match your ID with your photo and name to confirm that you are you." ) %>
+
+
<%- gettext( "What do you do with this picture?" ) %>
+
<%- 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." ) %>
+
+
+
+
+
+
+ <% if ( nextStepTitle ) { %>
+
+ <% } %>
+
+
diff --git a/lms/templates/verify_student/intro_step.underscore b/lms/templates/verify_student/intro_step.underscore
new file mode 100644
index 0000000000..deb52fb43f
--- /dev/null
+++ b/lms/templates/verify_student/intro_step.underscore
@@ -0,0 +1,76 @@
+
+
+
<%- introTitle %>
+
<%- introMsg %>
+
+
+
+ <% if ( requirements['photo-id-required'] ) { %>
+
+
<%- gettext( "Identification" ) %>
+
+
+
+
+
+
+
+ <%- gettext( "A photo identification document" ) %>
+ <%- gettext( "A driver's license, passport, or other government or school-issued ID with your name and picture on it." ) %>
+
+ <%- gettext( "A major credit or debit card" ) %>
+ <%- gettext( "Visa, MasterCard, American Express, Discover, Diners Club, or JCB with the Discover logo." ) %>
+
+
+
+ <% } %>
+
+
+ <% if ( nextStepTitle ) { %>
+
+ <% } %>
+
+
diff --git a/lms/templates/verify_student/make_payment_step.underscore b/lms/templates/verify_student/make_payment_step.underscore
new file mode 100644
index 0000000000..644148d1a7
--- /dev/null
+++ b/lms/templates/verify_student/make_payment_step.underscore
@@ -0,0 +1,87 @@
+
+
+
<%- gettext( "Make Payment" ) %>
+
+
<%- gettext( "Make payment. TODO: actual copy here." ) %>
+
+
+
+
+ <% if ( suggestedPrices.length > 0 ) { %>
+
+
<%- gettext( "Enter Your Contribution Level" ) %>
+
+
<%- _.sprintf(
+ gettext( "Please confirm your contribution for this course (min. $ %(minPrice)s %(currency)s)" ),
+ { minPrice: minPrice, currency: currency }
+ ) %>
+
+
+
+
+ <% for ( var i = 0; i < suggestedPrices.length; i++ ) {
+ price = suggestedPrices[i];
+ %>
+
+
+
+
+ <% } %>
+
+
+
+
+
+
+
+
+
+
+ $
+
+ <%- currency %>
+
+
+
+
+
+
+
+ <% } else {%>
+
+
<%- gettext( "Your Course Total" ) %>
+
+
<%- gettext( "To complete your registration, you will need to pay:" ) %>
+
+
+ ## 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.
+
+
+ ## Support
+
+
+
+
+
+
+%block>
diff --git a/lms/templates/verify_student/payment_confirmation_step.underscore b/lms/templates/verify_student/payment_confirmation_step.underscore
new file mode 100644
index 0000000000..27f6698326
--- /dev/null
+++ b/lms/templates/verify_student/payment_confirmation_step.underscore
@@ -0,0 +1,67 @@
+
+
+
<%- gettext( "Congratulations! You are now enrolled in the verified track." ) %>
+
+
<%- gettext( "You are now enrolled as a verified student! Your enrollment details are below.") %>
+
+
+
+
+
+ <%- gettext( "You are enrolled in " ) %> :
+
+
+
+
<%- gettext( "A list of courses you have just enrolled in as a verified student" ) %>
<%- _.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 } ) %>
+
+
+
+
+
+
+ <%- gettext( "What if the name on my account doesn't match the name on my ID?" ) %>
+
+
+
+
<%- gettext( "You should change the name on your account to match." ) %>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lms/templates/verify_student/webcam_photo.underscore b/lms/templates/verify_student/webcam_photo.underscore
new file mode 100644
index 0000000000..4572635ffc
--- /dev/null
+++ b/lms/templates/verify_student/webcam_photo.underscore
@@ -0,0 +1,30 @@
+
+
+
<%- gettext( "Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission." ) %>