Commit 90951a7b by Diana Huang

Merge branch 'release'

Conflicts:
	CHANGELOG.rst
	common/lib/xmodule/xmodule/js/fixtures/lti.html
	common/lib/xmodule/xmodule/js/spec/lti/constructor.js
	common/lib/xmodule/xmodule/js/src/lti/lti.js
	common/lib/xmodule/xmodule/lti_module.py
	lms/djangoapps/courseware/features/certificates.py
	lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
	lms/djangoapps/courseware/tests/test_lti.py
	lms/djangoapps/shoppingcart/models.py
	lms/envs/aws.py
parents 70cc5008 b542864b
...@@ -55,7 +55,11 @@ class CourseMode(models.Model): ...@@ -55,7 +55,11 @@ class CourseMode(models.Model):
@classmethod @classmethod
def modes_for_course_dict(cls, course_id): def modes_for_course_dict(cls, course_id):
return { mode.slug : mode for mode in cls.modes_for_course(course_id) } """
Returns the modes for a particular course as a dictionary with
the mode slug as the key
"""
return {mode.slug: mode for mode in cls.modes_for_course(course_id)}
@classmethod @classmethod
def mode_for_course(cls, course_id, mode_slug): def mode_for_course(cls, course_id, mode_slug):
......
...@@ -25,11 +25,18 @@ class ChooseModeView(View): ...@@ -25,11 +25,18 @@ class ChooseModeView(View):
if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified':
return redirect(reverse('dashboard')) return redirect(reverse('dashboard'))
modes = CourseMode.modes_for_course_dict(course_id) modes = CourseMode.modes_for_course_dict(course_id)
donation_for_course = request.session.get("donation_for_course", {})
chosen_price = donation_for_course.get(course_id, None)
course = course_from_id(course_id)
context = { context = {
"course_id": course_id, "course_id": course_id,
"modes": modes, "modes": modes,
"course_name": course_from_id(course_id).display_name, "course_name": course.display_name_with_default,
"chosen_price": None, "course_org" : course.display_org_with_default,
"course_num" : course.display_number_with_default,
"chosen_price": chosen_price,
"error": error, "error": error,
} }
if "verified" in modes: if "verified" in modes:
......
...@@ -183,7 +183,7 @@ class WeightedSubsectionsGrader(CourseGrader): ...@@ -183,7 +183,7 @@ class WeightedSubsectionsGrader(CourseGrader):
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores) subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
weighted_percent = subgrade_result['percent'] * weight weighted_percent = subgrade_result['percent'] * weight
section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight) section_detail = u"{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight)
total_percent += weighted_percent total_percent += weighted_percent
section_breakdown += subgrade_result['section_breakdown'] section_breakdown += subgrade_result['section_breakdown']
...@@ -224,14 +224,16 @@ class SingleSectionGrader(CourseGrader): ...@@ -224,14 +224,16 @@ class SingleSectionGrader(CourseGrader):
possible = found_score.possible possible = found_score.possible
percent = earned / float(possible) percent = earned / float(possible)
detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name, detail = u"{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(
name=self.name,
percent=percent, percent=percent,
earned=float(earned), earned=float(earned),
possible=float(possible)) possible=float(possible)
)
else: else:
percent = 0.0 percent = 0.0
detail = "{name} - 0% (?/?)".format(name=self.name) detail = u"{name} - 0% (?/?)".format(name=self.name)
breakdown = [{'percent': percent, 'label': self.short_label, breakdown = [{'percent': percent, 'label': self.short_label,
'detail': detail, 'category': self.category, 'prominent': True}] 'detail': detail, 'category': self.category, 'prominent': True}]
...@@ -323,20 +325,26 @@ class AssignmentFormatGrader(CourseGrader): ...@@ -323,20 +325,26 @@ class AssignmentFormatGrader(CourseGrader):
section_name = scores[i].section section_name = scores[i].section
percentage = earned / float(possible) percentage = earned / float(possible)
summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})" summary_format = u"{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})"
summary = summary_format.format(index=i + self.starting_index, summary = summary_format.format(
index=i + self.starting_index,
section_type=self.section_type, section_type=self.section_type,
name=section_name, name=section_name,
percent=percentage, percent=percentage,
earned=float(earned), earned=float(earned),
possible=float(possible)) possible=float(possible)
)
else: else:
percentage = 0 percentage = 0
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, summary = u"{section_type} {index} Unreleased - 0% (?/?)".format(
section_type=self.section_type) index=i + self.starting_index,
section_type=self.section_type
)
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label = u"{short_label} {index:02d}".format(
short_label=self.short_label) index=i + self.starting_index,
short_label=self.short_label
)
breakdown.append({'percent': percentage, 'label': short_label, breakdown.append({'percent': percentage, 'label': short_label,
'detail': summary, 'category': self.category}) 'detail': summary, 'category': self.category})
...@@ -344,22 +352,24 @@ class AssignmentFormatGrader(CourseGrader): ...@@ -344,22 +352,24 @@ class AssignmentFormatGrader(CourseGrader):
total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count) total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count)
for dropped_index in dropped_indices: for dropped_index in dropped_indices:
breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped." breakdown[dropped_index]['mark'] = {'detail': u"The lowest {drop_count} {section_type} scores are dropped."
.format(drop_count=self.drop_count, section_type=self.section_type)} .format(drop_count=self.drop_count, section_type=self.section_type)}
if len(breakdown) == 1: if len(breakdown) == 1:
# if there is only one entry in a section, suppress the existing individual entry and the average, # if there is only one entry in a section, suppress the existing individual entry and the average,
# and just display a single entry for the section. That way it acts automatically like a # and just display a single entry for the section. That way it acts automatically like a
# SingleSectionGrader. # SingleSectionGrader.
total_detail = "{section_type} = {percent:.0%}".format(percent=total_percent, total_detail = u"{section_type} = {percent:.0%}".format(percent=total_percent,
section_type=self.section_type) section_type=self.section_type)
total_label = "{short_label}".format(short_label=self.short_label) total_label = u"{short_label}".format(short_label=self.short_label)
breakdown = [{'percent': total_percent, 'label': total_label, breakdown = [{'percent': total_percent, 'label': total_label,
'detail': total_detail, 'category': self.category, 'prominent': True}, ] 'detail': total_detail, 'category': self.category, 'prominent': True}, ]
else: else:
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, total_detail = u"{section_type} Average = {percent:.0%}".format(
section_type=self.section_type) percent=total_percent,
total_label = "{short_label} Avg".format(short_label=self.short_label) section_type=self.section_type
)
total_label = u"{short_label} Avg".format(short_label=self.short_label)
if self.show_only_average: if self.show_only_average:
breakdown = [] breakdown = []
......
...@@ -128,7 +128,7 @@ $(document).ready(function() { ...@@ -128,7 +128,7 @@ $(document).ready(function() {
<h3 class="title">${_("What is an ID Verified Certificate?")}</h3> <h3 class="title">${_("What is an ID Verified Certificate?")}</h3>
<div class="copy"> <div class="copy">
<p>${_("Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim.")}</p> <p>${_("An ID Verified Certificate requires proof of your identity through your photo and ID and is checked throughout the course to verify that it is you who earned the passing grade.")}</p>
</div> </div>
</div> </div>
% endif % endif
......
...@@ -28,9 +28,6 @@ Feature: Verified certificates ...@@ -28,9 +28,6 @@ Feature: Verified certificates
When I submit valid payment information When I submit valid payment information
Then I see that my payment was successful Then I see that my payment was successful
# Not yet implemented LMS-982
@skip
Scenario: Verified courses display correctly on dashboard Scenario: Verified courses display correctly on dashboard
Given I have submitted photos to verify my identity Given I have submitted photos to verify my identity
When I submit valid payment information When I submit valid payment information
...@@ -57,8 +54,6 @@ Feature: Verified certificates ...@@ -57,8 +54,6 @@ Feature: Verified certificates
When I edit my name When I edit my name
Then I see the new name on the confirmation page. Then I see the new name on the confirmation page.
# Currently broken LMS-1009
@skip
Scenario: I can return to the verify flow Scenario: I can return to the verify flow
Given I have submitted photos to verify my identity Given I have submitted photos to verify my identity
When I leave the flow and return When I leave the flow and return
...@@ -72,9 +67,8 @@ Feature: Verified certificates ...@@ -72,9 +67,8 @@ Feature: Verified certificates
And I press the payment button And I press the payment button
Then I am at the payment page Then I am at the payment page
# Design not yet finalized
@skip
Scenario: I can take a verified certificate course for free Scenario: I can take a verified certificate course for free
Given I have submitted photos to verify my identity Given I am logged in
And the course has an honor mode
When I give a reason why I cannot pay When I give a reason why I cannot pay
Then I see that I am registered for a verified certificate course on my dashboard Then I should see the course on my dashboard
...@@ -13,6 +13,7 @@ def create_cert_course(): ...@@ -13,6 +13,7 @@ def create_cert_course():
name = 'Certificates' name = 'Certificates'
course_id = '{org}/{number}/{name}'.format( course_id = '{org}/{number}/{name}'.format(
org=org, number=number, name=name) org=org, number=number, name=name)
world.scenario_dict['course_id'] = course_id
world.scenario_dict['COURSE'] = world.CourseFactory.create( world.scenario_dict['COURSE'] = world.CourseFactory.create(
org=org, number=number, display_name=name) org=org, number=number, display_name=name)
...@@ -44,6 +45,18 @@ def register(): ...@@ -44,6 +45,18 @@ def register():
assert world.is_css_present('section.wrapper h3.title') assert world.is_css_present('section.wrapper h3.title')
@step(u'the course has an honor mode')
def the_course_has_an_honor_mode(step):
create_cert_course()
honor_mode = world.CourseModeFactory.create(
course_id=world.scenario_dict['course_id'],
mode_slug='honor',
mode_display_name='honor mode',
min_price=0,
)
assert isinstance(honor_mode, CourseMode)
@step(u'I select the audit track$') @step(u'I select the audit track$')
def select_the_audit_track(step): def select_the_audit_track(step):
create_cert_course() create_cert_course()
...@@ -80,8 +93,8 @@ def should_see_the_course_on_my_dashboard(step): ...@@ -80,8 +93,8 @@ def should_see_the_course_on_my_dashboard(step):
def goto_next_step(step, step_num): def goto_next_step(step, step_num):
btn_css = { btn_css = {
'1': '#face_next_button', '1': '#face_next_button',
'2': '#face_next_button', '2': '#face_next_link',
'3': '#photo_id_next_button', '3': '#photo_id_next_link',
'4': '#pay_button', '4': '#pay_button',
} }
next_css = { next_css = {
...@@ -100,15 +113,9 @@ def goto_next_step(step, step_num): ...@@ -100,15 +113,9 @@ def goto_next_step(step, step_num):
@step(u'I capture my "([^"]*)" photo$') @step(u'I capture my "([^"]*)" photo$')
def capture_my_photo(step, name): def capture_my_photo(step, name):
# Draw a red rectangle in the image element # Hard coded red dot image
snapshot_script = '"{}{}{}{}{}{}"'.format( image_data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
"var canvas = $('#{}_canvas');".format(name), snapshot_script = "$('#{}_image')[0].src = '{}';".format(name, image_data)
"var ctx = canvas[0].getContext('2d');",
"ctx.fillStyle = 'rgb(200,0,0)';",
"ctx.fillRect(0, 0, 640, 480);",
"var image = $('#{}_image');".format(name),
"image[0].src = canvas[0].toDataURL('image/png').replace('image/png', 'image/octet-stream');"
)
# Mirror the javascript of the photo_verification.html page # Mirror the javascript of the photo_verification.html page
world.browser.execute_script(snapshot_script) world.browser.execute_script(snapshot_script)
...@@ -171,8 +178,8 @@ def submit_payment(step): ...@@ -171,8 +178,8 @@ def submit_payment(step):
world.css_click(button_css) world.css_click(button_css)
@step(u'I have submitted photos to verify my identity') @step(u'I have submitted face and ID photos$')
def submitted_photos_to_verify_my_identity(step): def submitted_face_and_id_photos(step):
step.given('I am logged in') step.given('I am logged in')
step.given('I select the verified track') step.given('I select the verified track')
step.given('I go to step "1"') step.given('I go to step "1"')
...@@ -182,6 +189,11 @@ def submitted_photos_to_verify_my_identity(step): ...@@ -182,6 +189,11 @@ def submitted_photos_to_verify_my_identity(step):
step.given('I capture my "photo_id" photo') step.given('I capture my "photo_id" photo')
step.given('I approve my "photo_id" photo') step.given('I approve my "photo_id" photo')
step.given('I go to step "3"') step.given('I go to step "3"')
@step(u'I have submitted photos to verify my identity')
def submitted_photos_to_verify_my_identity(step):
step.given('I have submitted face and ID photos')
step.given('I select a contribution amount') step.given('I select a contribution amount')
step.given('I confirm that the details match') step.given('I confirm that the details match')
step.given('I go to step "4"') step.given('I go to step "4"')
...@@ -207,14 +219,38 @@ def see_the_course_on_my_dashboard(step): ...@@ -207,14 +219,38 @@ def see_the_course_on_my_dashboard(step):
@step(u'I see that I am on the verified track') @step(u'I see that I am on the verified track')
def see_that_i_am_on_the_verified_track(step): def see_that_i_am_on_the_verified_track(step):
assert False, 'Implement this step after the design is done' id_verified_css = 'li.course-item article.course.verified'
assert world.is_css_present(id_verified_css)
@step(u'I leave the flow and return$') @step(u'I leave the flow and return$')
def leave_the_flow_and_return(step): def leave_the_flow_and_return(step):
world.browser.back() world.visit('verify_student/verified/edx/999/Certificates')
@step(u'I am at the verified page$') @step(u'I am at the verified page$')
def see_the_payment_page(step): def see_the_payment_page(step):
assert world.css_find('button#pay_button') assert world.css_find('button#pay_button')
@step(u'I edit my name$')
def edit_my_name(step):
btn_css = 'a.retake-photos'
world.css_click(btn_css)
@step(u'I give a reason why I cannot pay$')
def give_a_reason_why_i_cannot_pay(step):
register()
link_css = 'h5 i.expandable-icon'
world.css_click(link_css)
cb_css = 'input#honor-code'
world.css_click(cb_css)
text_css = 'li.field-explain textarea'
world.css_find(text_css).type('I cannot afford it.')
btn_css = 'input[value="Select Certificate"]'
world.css_click(btn_css)
...@@ -38,6 +38,8 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): ...@@ -38,6 +38,8 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator):
def get_dynamic_descriptor_children(descriptor): def get_dynamic_descriptor_children(descriptor):
if descriptor.has_dynamic_children(): if descriptor.has_dynamic_children():
module = module_creator(descriptor) module = module_creator(descriptor)
if module is None:
return []
return module.get_child_descriptors() return module.get_child_descriptors()
else: else:
return descriptor.get_children() return descriptor.get_children()
......
...@@ -19,6 +19,7 @@ from mitxmako.shortcuts import render_to_string ...@@ -19,6 +19,7 @@ from mitxmako.shortcuts import render_to_string
from student.views import course_from_id from student.views import course_from_id
from student.models import CourseEnrollment from student.models import CourseEnrollment
from dogapi import dog_stats_api from dogapi import dog_stats_api
from verify_student.models import SoftwareSecurePhotoVerification
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -371,6 +372,14 @@ class CertificateItem(OrderItem): ...@@ -371,6 +372,14 @@ class CertificateItem(OrderItem):
""" """
When purchase goes through, activate and update the course enrollment for the correct mode When purchase goes through, activate and update the course enrollment for the correct mode
""" """
try:
verification_attempt = SoftwareSecurePhotoVerification.active_for_user(self.course_enrollment.user)
verification_attempt.submit()
except Exception as e:
log.exception(
"Could not submit verification attempt for enrollment {}".format(self.course_enrollment)
)
self.course_enrollment.mode = self.mode self.course_enrollment.mode = self.mode
self.course_enrollment.save() self.course_enrollment.save()
self.course_enrollment.activate() self.course_enrollment.activate()
...@@ -383,8 +392,21 @@ class CertificateItem(OrderItem): ...@@ -383,8 +392,21 @@ class CertificateItem(OrderItem):
return super(CertificateItem, self).single_item_receipt_template return super(CertificateItem, self).single_item_receipt_template
@property @property
def single_item_receipt_context(self):
course = course_from_id(self.course_id)
return {
"course_id" : self.course_id,
"course_name": course.display_name_with_default,
"course_org": course.display_org_with_default,
"course_num": course.display_number_with_default,
"course_start_date_text": course.start_date_text,
"course_has_started": course.start > datetime.today().replace(tzinfo=pytz.utc),
}
@property
def additional_instruction_text(self): def additional_instruction_text(self):
return textwrap.dedent( return _("Note - you have up to 2 weeks into the course to unenroll from the Verified Certificate option "
_("Note - you have up to 2 weeks into the course to unenroll from the Verified Certificate option \ "and receive a full refund. To receive your refund, contact {billing_email}. "
and receive a full refund. To receive your refund, contact {billing_email}.").format( "Please include your order number in your e-mail. "
billing_email=settings.PAYMENT_SUPPORT_EMAIL)) "Please do NOT include your credit card information.").format(
billing_email=settings.PAYMENT_SUPPORT_EMAIL)
...@@ -113,5 +113,6 @@ def show_receipt(request, ordernum): ...@@ -113,5 +113,6 @@ def show_receipt(request, ordernum):
if order_items.count() == 1: if order_items.count() == 1:
receipt_template = order_items[0].single_item_receipt_template receipt_template = order_items[0].single_item_receipt_template
context.update(order_items[0].single_item_receipt_context)
return render_to_response(receipt_template, context) return render_to_response(receipt_template, context)
from ratelimitbackend import admin
from verify_student.models import SoftwareSecurePhotoVerification
admin.site.register(SoftwareSecurePhotoVerification)
...@@ -9,22 +9,30 @@ of a student over a period of time. Right now, the only models are the abstract ...@@ -9,22 +9,30 @@ of a student over a period of time. Right now, the only models are the abstract
photo verification process as generic as possible. photo verification process as generic as possible.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from email.utils import formatdate
from hashlib import md5 from hashlib import md5
import base64 import base64
import functools import functools
import json
import logging import logging
import uuid import uuid
from boto.s3.connection import S3Connection
from boto.s3.key import Key
import pytz import pytz
import requests
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from model_utils.models import StatusModel from model_utils.models import StatusModel
from model_utils import Choices from model_utils import Choices
from verify_student.ssencrypt import ( from verify_student.ssencrypt import (
random_aes_key, decode_and_decrypt, encrypt_and_encode random_aes_key, decode_and_decrypt, encrypt_and_encode,
generate_signed_message, rsa_encrypt
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -86,6 +94,9 @@ class PhotoVerification(StatusModel): ...@@ -86,6 +94,9 @@ class PhotoVerification(StatusModel):
`submitted` `submitted`
Submitted for review. The review may be done by a staff member or an Submitted for review. The review may be done by a staff member or an
external service. The user cannot make changes once in this state. external service. The user cannot make changes once in this state.
`must_retry`
We submitted this, but there was an error on submission (i.e. we did not
get a 200 when we POSTed to Software Secure)
`approved` `approved`
An admin or an external service has confirmed that the user's photo and An admin or an external service has confirmed that the user's photo and
photo ID match up, and that the photo ID's name matches the user's. photo ID match up, and that the photo ID's name matches the user's.
...@@ -106,7 +117,7 @@ class PhotoVerification(StatusModel): ...@@ -106,7 +117,7 @@ class PhotoVerification(StatusModel):
######################## Fields Set During Creation ######################## ######################## Fields Set During Creation ########################
# See class docstring for description of status states # See class docstring for description of status states
STATUS = Choices('created', 'ready', 'submitted', 'approved', 'denied') STATUS = Choices('created', 'ready', 'submitted', 'must_retry', 'approved', 'denied')
user = models.ForeignKey(User, db_index=True) user = models.ForeignKey(User, db_index=True)
# They can change their name later on, so we want to copy the value here so # They can change their name later on, so we want to copy the value here so
...@@ -183,7 +194,7 @@ class PhotoVerification(StatusModel): ...@@ -183,7 +194,7 @@ class PhotoVerification(StatusModel):
""" """
TODO: eliminate duplication with user_is_verified TODO: eliminate duplication with user_is_verified
""" """
valid_statuses = ['ready', 'submitted', 'approved'] valid_statuses = ['must_retry', 'submitted', 'approved']
earliest_allowed_date = ( earliest_allowed_date = (
earliest_allowed_date or earliest_allowed_date or
datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR) datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR)
...@@ -205,7 +216,7 @@ class PhotoVerification(StatusModel): ...@@ -205,7 +216,7 @@ class PhotoVerification(StatusModel):
""" """
# This should only be one at the most, but just in case we create more # This should only be one at the most, but just in case we create more
# by mistake, we'll grab the most recently created one. # by mistake, we'll grab the most recently created one.
active_attempts = cls.objects.filter(user=user, status='created') active_attempts = cls.objects.filter(user=user, status='ready').order_by('-created_at')
if active_attempts: if active_attempts:
return active_attempts[0] return active_attempts[0]
else: else:
...@@ -246,10 +257,10 @@ class PhotoVerification(StatusModel): ...@@ -246,10 +257,10 @@ class PhotoVerification(StatusModel):
they uploaded are good. Note that we don't actually do a submission they uploaded are good. Note that we don't actually do a submission
anywhere yet. anywhere yet.
""" """
if not self.face_image_url: # if not self.face_image_url:
raise VerificationException("No face image was uploaded.") # raise VerificationException("No face image was uploaded.")
if not self.photo_id_image_url: # if not self.photo_id_image_url:
raise VerificationException("No photo ID image was uploaded.") # raise VerificationException("No photo ID image was uploaded.")
# At any point prior to this, they can change their names via their # At any point prior to this, they can change their names via their
# student dashboard. But at this point, we lock the value into the # student dashboard. But at this point, we lock the value into the
...@@ -258,18 +269,11 @@ class PhotoVerification(StatusModel): ...@@ -258,18 +269,11 @@ class PhotoVerification(StatusModel):
self.status = "ready" self.status = "ready"
self.save() self.save()
@status_before_must_be("ready", "submit") @status_before_must_be("must_retry", "ready", "submitted")
def submit(self, reviewing_service=None): def submit(self):
if self.status == "submitted": raise NotImplementedError
return
if reviewing_service:
reviewing_service.submit(self)
self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted"
self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("must_retry", "submitted", "approved", "denied")
def approve(self, user_id=None, service=""): def approve(self, user_id=None, service=""):
""" """
Approve this attempt. `user_id` Approve this attempt. `user_id`
...@@ -309,7 +313,7 @@ class PhotoVerification(StatusModel): ...@@ -309,7 +313,7 @@ class PhotoVerification(StatusModel):
self.status = "approved" self.status = "approved"
self.save() self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("must_retry", "submitted", "approved", "denied")
def deny(self, def deny(self,
error_msg, error_msg,
error_code="", error_code="",
...@@ -384,25 +388,133 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -384,25 +388,133 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
# encode that. The result is saved here. Actual expected length is 344. # encode that. The result is saved here. Actual expected length is 344.
photo_id_key = models.TextField(max_length=1024) photo_id_key = models.TextField(max_length=1024)
IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds
@status_before_must_be("created") @status_before_must_be("created")
def upload_face_image(self, img_data): def upload_face_image(self, img_data):
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"] aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
aes_key = aes_key_str.decode("hex") aes_key = aes_key_str.decode("hex")
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
# Upload it to S3 s3_key = self._generate_key("face")
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
@status_before_must_be("created") @status_before_must_be("created")
def upload_photo_id_image(self, img_data): def upload_photo_id_image(self, img_data):
aes_key = random_aes_key() aes_key = random_aes_key()
encrypted_img_data = self._encrypt_image_data(img_data, aes_key) rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
b64_encoded_img_data = base64.encodestring(encrypted_img_data) rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str)
# Upload this to S3 # Upload this to S3
s3_key = self._generate_key("photo_id")
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
# Update our record fields
self.photo_id_key = rsa_encrypted_aes_key.encode('base64')
self.save()
rsa_key = RSA.importKey( @status_before_must_be("must_retry", "ready", "submitted")
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"] def submit(self):
try:
response = self.send_request()
if response.ok:
self.submitted_at = datetime.now(pytz.UTC)
self.status = "submitted"
self.save()
else:
self.status = "must_retry"
self.error_msg = response.text
self.save()
except Exception as e:
log.exception(e)
def image_url(self, name):
"""
We dynamically generate this, since we want it the expiration clock to
start when the message is created, not when the record is created.
"""
s3_key = self._generate_key(name)
return s3_key.generate_url(self.IMAGE_LINK_DURATION)
def _generate_key(self, prefix):
"""
face/4dd1add9-6719-42f7-bea0-115c008c4fca
"""
conn = S3Connection(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_ACCESS_KEY"],
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["AWS_SECRET_KEY"]
)
bucket = conn.get_bucket(settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["S3_BUCKET"])
key = Key(bucket)
key.key = "{}/{}".format(prefix, self.receipt_id);
return key
def _encrypted_user_photo_key_str(self):
"""
Software Secure needs to have both UserPhoto and PhotoID decrypted in
the same manner. So even though this is going to be the same for every
request, we're also using RSA encryption to encrypt the AES key for
faces.
"""
face_aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
face_aes_key = face_aes_key_str.decode("hex")
rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
rsa_encrypted_face_aes_key = rsa_encrypt(face_aes_key, rsa_key_str)
return rsa_encrypted_face_aes_key.encode("base64")
def create_request(self):
"""return headers, body_dict"""
access_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"]
secret_key = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
scheme = "https" if settings.HTTPS == "on" else "http"
callback_url = "{}://{}{}".format(
scheme, settings.SITE_NAME, reverse('verify_student_results_callback')
) )
rsa_cipher = PKCS1_OAEP.new(key)
rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key) body = {
"EdX-ID": str(self.receipt_id),
"ExpectedName": self.user.profile.name,
"PhotoID": self.image_url("photo_id"),
"PhotoIDKey": self.photo_id_key,
"SendResponseTo": callback_url,
"UserPhoto": self.image_url("face"),
"UserPhotoKey": self._encrypted_user_photo_key_str(),
}
headers = {
"Content-Type": "application/json",
"Date": formatdate(timeval=None, localtime=False, usegmt=True)
}
message, _, authorization = generate_signed_message(
"POST", headers, body, access_key, secret_key
)
headers['Authorization'] = authorization
return headers, body
def request_message_txt(self):
headers, body = self.create_request()
header_txt = "\n".join(
"{}: {}".format(h, v) for h,v in sorted(headers.items())
)
body_txt = json.dumps(body, indent=2, sort_keys=True)
return header_txt + "\n\n" + body_txt
def send_request(self):
headers, body = self.create_request()
response = requests.post(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_URL"],
headers=headers,
data=json.dumps(body, indent=2, sort_keys=True)
)
log.debug("Sent request to Software Secure for {}".format(self.receipt_id))
log.debug("Headers:\n{}\n\n".format(headers))
log.debug("Body:\n{}\n\n".format(body))
log.debug("Return code: {}".format(response.status_code))
log.debug("Return message:\n\n{}\n\n".format(response.text))
return response
\ No newline at end of file
...@@ -22,13 +22,22 @@ In case of PEM encoding, the private key can be encrypted with DES or 3TDES ...@@ -22,13 +22,22 @@ In case of PEM encoding, the private key can be encrypted with DES or 3TDES
according to a certain pass phrase. Only OpenSSL-compatible pass phrases are according to a certain pass phrase. Only OpenSSL-compatible pass phrases are
supported. supported.
""" """
from hashlib import md5 from collections import OrderedDict
from email.utils import formatdate
from hashlib import md5, sha256
from uuid import uuid4
import base64 import base64
import binascii
import json
import hmac
import logging
import sys
from Crypto import Random from Crypto import Random
from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
log = logging.getLogger(__name__)
def encrypt_and_encode(data, key): def encrypt_and_encode(data, key):
return base64.urlsafe_b64encode(aes_encrypt(data, key)) return base64.urlsafe_b64encode(aes_encrypt(data, key))
...@@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str): ...@@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str):
key = RSA.importKey(rsa_priv_key_str) key = RSA.importKey(rsa_priv_key_str)
cipher = PKCS1_OAEP.new(key) cipher = PKCS1_OAEP.new(key)
return cipher.decrypt(data) return cipher.decrypt(data)
def has_valid_signature(method, headers_dict, body_dict, access_key, secret_key):
"""
Given a message (either request or response), say whether it has a valid
signature or not.
"""
_, expected_signature, _ = generate_signed_message(
method, headers_dict, body_dict, access_key, secret_key
)
authorization = headers_dict["Authorization"]
auth_token, post_signature = authorization.split(":")
_, post_access_key = auth_token.split()
if post_access_key != access_key:
log.error("Posted access key does not match ours")
log.debug("Their access: %s; Our access: %s", post_access_key, access_key)
return False
if post_signature != expected_signature:
log.error("Posted signature does not match expected")
log.debug("Their sig: %s; Expected: %s", post_signature, expected_signature)
return False
return True
def generate_signed_message(method, headers_dict, body_dict, access_key, secret_key):
"""
Returns a (message, signature) pair.
"""
headers_str = "{}\n\n{}".format(method, header_string(headers_dict))
body_str = body_string(body_dict)
message = headers_str + body_str
hashed = hmac.new(secret_key, message, sha256)
signature = binascii.b2a_base64(hashed.digest()).rstrip('\n')
authorization_header = "SSI {}:{}".format(access_key, signature)
message += '\n'
return message, signature, authorization_header
def header_string(headers_dict):
"""Given a dictionary of headers, return a canonical string representation."""
header_list = []
if 'Content-Type' in headers_dict:
header_list.append(headers_dict['Content-Type'] + "\n")
if 'Date' in headers_dict:
header_list.append(headers_dict['Date'] + "\n")
if 'Content-MD5' in headers_dict:
header_list.append(headers_dict['Content-MD5'] + "\n")
return "".join(header_list) # Note that trailing \n's are important
def body_string(body_dict):
"""
This version actually doesn't support nested lists and dicts. The code for
that was a little gnarly and we don't use that functionality, so there's no
real test for correctness.
"""
body_list = []
for key, value in sorted(body_dict.items()):
if value is None:
value = "null"
body_list.append(u"{}:{}\n".format(key, value))
return "".join(body_list) # Note that trailing \n's are important
...@@ -23,9 +23,6 @@ class TestPhotoVerification(TestCase): ...@@ -23,9 +23,6 @@ class TestPhotoVerification(TestCase):
assert_equals(attempt.status, SoftwareSecurePhotoVerification.STATUS.created) assert_equals(attempt.status, SoftwareSecurePhotoVerification.STATUS.created)
assert_equals(attempt.status, "created") assert_equals(attempt.status, "created")
# This should fail because we don't have the necessary fields filled out
assert_raises(VerificationException, attempt.mark_ready)
# These should all fail because we're in the wrong starting state. # These should all fail because we're in the wrong starting state.
assert_raises(VerificationException, attempt.submit) assert_raises(VerificationException, attempt.submit)
assert_raises(VerificationException, attempt.approve) assert_raises(VerificationException, attempt.approve)
...@@ -47,14 +44,14 @@ class TestPhotoVerification(TestCase): ...@@ -47,14 +44,14 @@ class TestPhotoVerification(TestCase):
assert_raises(VerificationException, attempt.deny) assert_raises(VerificationException, attempt.deny)
# Now we submit # Now we submit
attempt.submit() #attempt.submit()
assert_equals(attempt.status, "submitted") #assert_equals(attempt.status, "submitted")
# So we should be able to both approve and deny # So we should be able to both approve and deny
attempt.approve() #attempt.approve()
assert_equals(attempt.status, "approved") #assert_equals(attempt.status, "approved")
attempt.deny("Could not read name on Photo ID") #attempt.deny("Could not read name on Photo ID")
assert_equals(attempt.status, "denied") #assert_equals(attempt.status, "denied")
...@@ -30,9 +30,16 @@ urlpatterns = patterns( ...@@ -30,9 +30,16 @@ urlpatterns = patterns(
), ),
url( url(
r'^results_callback$',
views.results_callback,
name="verify_student_results_callback",
),
url(
r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$', r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$',
views.show_verification_page, views.show_verification_page,
name="verify_student/show_verification_page" name="verify_student/show_verification_page"
), ),
) )
...@@ -12,6 +12,8 @@ from django.conf import settings ...@@ -12,6 +12,8 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import redirect 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.views.generic.base import View
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -26,6 +28,7 @@ from shoppingcart.processors.CyberSource import ( ...@@ -26,6 +28,7 @@ from shoppingcart.processors.CyberSource import (
get_signed_purchase_params, get_purchase_endpoint get_signed_purchase_params, get_purchase_endpoint
) )
from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import SoftwareSecurePhotoVerification
import ssencrypt
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -55,11 +58,15 @@ class VerifyView(View): ...@@ -55,11 +58,15 @@ class VerifyView(View):
chosen_price = request.session["donation_for_course"][course_id] chosen_price = request.session["donation_for_course"][course_id]
else: else:
chosen_price = verify_mode.min_price chosen_price = verify_mode.min_price
course = course_from_id(course_id)
context = { context = {
"progress_state": progress_state, "progress_state": progress_state,
"user_full_name": request.user.profile.name, "user_full_name": request.user.profile.name,
"course_id": course_id, "course_id": course_id,
"course_name": course_from_id(course_id).display_name, "course_name": course.display_name_with_default,
"course_org" : course.display_org_with_default,
"course_num" : course.display_number_with_default,
"purchase_endpoint": get_purchase_endpoint(), "purchase_endpoint": get_purchase_endpoint(),
"suggested_prices": [ "suggested_prices": [
decimal.Decimal(price) decimal.Decimal(price)
...@@ -91,9 +98,12 @@ class VerifiedView(View): ...@@ -91,9 +98,12 @@ class VerifiedView(View):
else: else:
chosen_price = verify_mode.min_price.format("{:g}") chosen_price = verify_mode.min_price.format("{:g}")
course = course_from_id(course_id)
context = { context = {
"course_id": course_id, "course_id": course_id,
"course_name": course_from_id(course_id).display_name, "course_name": course.display_name_with_default,
"course_org" : course.display_org_with_default,
"course_num" : course.display_number_with_default,
"purchase_endpoint": get_purchase_endpoint(), "purchase_endpoint": get_purchase_endpoint(),
"currency": verify_mode.currency.upper(), "currency": verify_mode.currency.upper(),
"chosen_price": chosen_price, "chosen_price": chosen_price,
...@@ -108,7 +118,13 @@ def create_order(request): ...@@ -108,7 +118,13 @@ def create_order(request):
""" """
if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
attempt = SoftwareSecurePhotoVerification(user=request.user) attempt = SoftwareSecurePhotoVerification(user=request.user)
attempt.status = "ready" b64_face_image = request.POST['face_image'].split(",")[1]
b64_photo_id_image = request.POST['photo_id_image'].split(",")[1]
attempt.upload_face_image(b64_face_image.decode('base64'))
attempt.upload_photo_id_image(b64_photo_id_image.decode('base64'))
attempt.mark_ready()
attempt.save() attempt.save()
course_id = request.POST['course_id'] course_id = request.POST['course_id']
...@@ -142,6 +158,45 @@ def create_order(request): ...@@ -142,6 +158,45 @@ def create_order(request):
return HttpResponse(json.dumps(params), content_type="text/json") return HttpResponse(json.dumps(params), content_type="text/json")
@require_POST
@csrf_exempt # SS does its own message signing, and their API won't have a cookie value
def results_callback(request):
"""
Software Secure will call this callback to tell us whether a user is
verified to be who they said they are.
"""
body = request.body
body_dict = json.loads(body)
headers = {
"Authorization": request.META.get("HTTP_AUTHORIZATION", ""),
"Date": request.META.get("HTTP_DATE", "")
}
sig_valid = ssencrypt.has_valid_signature(
"POST",
headers,
body_dict,
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_ACCESS_KEY"],
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["API_SECRET_KEY"]
)
if not sig_valid:
return HttpResponseBadRequest(_("Signature is invalid"))
receipt_id = body_dict.get("EdX-ID")
result = body_dict.get("Result")
reason = body_dict.get("Reason", "")
error_code = body_dict.get("MessageType", "")
attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=receipt_id)
if result == "PASSED":
attempt.approve()
elif result == "FAILED":
attempt.deny(reason, error_code=error_code)
elif result == "SYSTEM FAIL":
log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason)
return HttpResponse("OK!")
@login_required @login_required
def show_requirements(request, course_id): def show_requirements(request, course_id):
...@@ -150,10 +205,14 @@ def show_requirements(request, course_id): ...@@ -150,10 +205,14 @@ def show_requirements(request, course_id):
""" """
if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified': if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified':
return redirect(reverse('dashboard')) return redirect(reverse('dashboard'))
course = course_from_id(course_id)
context = { context = {
"course_id": course_id, "course_id": course_id,
"course_name": course.display_name_with_default,
"course_org" : course.display_org_with_default,
"course_num" : course.display_number_with_default,
"is_not_active": not request.user.is_active, "is_not_active": not request.user.is_active,
"course_name": course_from_id(course_id).display_name,
} }
return render_to_response("verify_student/show_requirements.html", context) return render_to_response("verify_student/show_requirements.html", context)
...@@ -161,7 +220,6 @@ def show_requirements(request, course_id): ...@@ -161,7 +220,6 @@ def show_requirements(request, course_id):
def show_verification_page(request): def show_verification_page(request):
pass pass
def enroll(user, course_id, mode_slug): def enroll(user, course_id, mode_slug):
""" """
Enroll the user in a course for a certain mode. Enroll the user in a course for a certain mode.
...@@ -203,7 +261,6 @@ def enroll(user, course_id, mode_slug): ...@@ -203,7 +261,6 @@ def enroll(user, course_id, mode_slug):
# Create a VerifiedCertificate order item # Create a VerifiedCertificate order item
return HttpResponse.Redirect(reverse('verified')) return HttpResponse.Redirect(reverse('verified'))
# There's always at least one mode available (default is "honor"). If they # There's always at least one mode available (default is "honor"). If they
# haven't specified a mode, we just assume it's # haven't specified a mode, we just assume it's
if not mode: if not mode:
......
...@@ -262,3 +262,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT, ...@@ -262,3 +262,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
# Event tracking # Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
# Student identity verification settings
VERIFY_STUDENT = AUTH_TOKENS.get("VERIFY_STUDENT", "")
...@@ -19,7 +19,7 @@ DEBUG = True ...@@ -19,7 +19,7 @@ DEBUG = True
TEMPLATE_DEBUG = True TEMPLATE_DEBUG = True
MITX_FEATURES['DISABLE_START_DATES'] = True MITX_FEATURES['DISABLE_START_DATES'] = False
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
MITX_FEATURES['SUBDOMAIN_BRANDING'] = True MITX_FEATURES['SUBDOMAIN_BRANDING'] = True
......
...@@ -18,11 +18,13 @@ package ...@@ -18,11 +18,13 @@ package
import flash.display.PNGEncoderOptions; import flash.display.PNGEncoderOptions;
import flash.display.Sprite; import flash.display.Sprite;
import flash.events.Event; import flash.events.Event;
import flash.events.StatusEvent;
import flash.external.ExternalInterface; import flash.external.ExternalInterface;
import flash.geom.Rectangle; import flash.geom.Rectangle;
import flash.media.Camera; import flash.media.Camera;
import flash.media.Video; import flash.media.Video;
import flash.utils.ByteArray; import flash.utils.ByteArray;
import mx.utils.Base64Encoder; import mx.utils.Base64Encoder;
[SWF(width="640", height="480")] [SWF(width="640", height="480")]
...@@ -35,6 +37,7 @@ package ...@@ -35,6 +37,7 @@ package
private var camera:Camera; private var camera:Camera;
private var video:Video; private var video:Video;
private var b64EncodedImage:String = null; private var b64EncodedImage:String = null;
private var permissionGiven:Boolean = false;
public function CameraCapture() public function CameraCapture()
{ {
...@@ -44,6 +47,7 @@ package ...@@ -44,6 +47,7 @@ package
protected function init(e:Event):void { protected function init(e:Event):void {
camera = Camera.getCamera(); camera = Camera.getCamera();
camera.setMode(VIDEO_WIDTH, VIDEO_HEIGHT, 30); camera.setMode(VIDEO_WIDTH, VIDEO_HEIGHT, 30);
camera.addEventListener(StatusEvent.STATUS, statusHandler);
video = new Video(VIDEO_WIDTH, VIDEO_HEIGHT); video = new Video(VIDEO_WIDTH, VIDEO_HEIGHT);
video.attachCamera(camera); video.attachCamera(camera);
...@@ -53,6 +57,8 @@ package ...@@ -53,6 +57,8 @@ package
ExternalInterface.addCallback("snap", snap); ExternalInterface.addCallback("snap", snap);
ExternalInterface.addCallback("reset", reset); ExternalInterface.addCallback("reset", reset);
ExternalInterface.addCallback("imageDataUrl", imageDataUrl); ExternalInterface.addCallback("imageDataUrl", imageDataUrl);
ExternalInterface.addCallback("cameraAuthorized", cameraAuthorized);
ExternalInterface.addCallback("hasCamera", hasCamera);
// Notify the container that the SWF is ready to be called. // Notify the container that the SWF is ready to be called.
ExternalInterface.call("setSWFIsReady"); ExternalInterface.call("setSWFIsReady");
...@@ -98,6 +104,28 @@ package ...@@ -98,6 +104,28 @@ package
} }
return ""; return "";
} }
public function cameraAuthorized():Boolean {
return permissionGiven;
}
public function hasCamera():Boolean {
return (Camera.names.length != 0);
}
public function statusHandler(event:StatusEvent):void {
switch (event.code)
{
case "Camera.Muted":
// User clicked Deny.
permissionGiven = false;
break;
case "Camera.Unmuted":
// "User clicked Accept.
permissionGiven = true;
break;
}
}
} }
} }
var onVideoFail = function(e) { var onVideoFail = function(e) {
if(e == 'NO_DEVICES_FOUND') {
$('#no-webcam').show();
}
else {
console.log('Failed to get camera access!', e); console.log('Failed to get camera access!', e);
}
}; };
// Returns true if we are capable of video capture (regardless of whether the // Returns true if we are capable of video capture (regardless of whether the
...@@ -27,7 +32,9 @@ var submitToPaymentProcessing = function() { ...@@ -27,7 +32,9 @@ var submitToPaymentProcessing = function() {
"/verify_student/create_order", "/verify_student/create_order",
{ {
"course_id" : course_id, "course_id" : course_id,
"contribution": contribution "contribution": contribution,
"face_image" : $("#face_image")[0].src,
"photo_id_image" : $("#photo_id_image")[0].src
}, },
function(data) { function(data) {
for (prop in data) { for (prop in data) {
...@@ -47,18 +54,20 @@ var submitToPaymentProcessing = function() { ...@@ -47,18 +54,20 @@ var submitToPaymentProcessing = function() {
}); });
} }
function doResetButton(resetButton, captureButton, approveButton, nextButton) { function doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink) {
approveButton.removeClass('approved'); approveButton.removeClass('approved');
nextButton.addClass('disabled'); nextButtonNav.addClass('is-not-ready');
nextLink.attr('href', "#");
captureButton.show(); captureButton.show();
resetButton.hide(); resetButton.hide();
approveButton.hide(); approveButton.hide();
} }
function doApproveButton(approveButton, nextButton) { function doApproveButton(approveButton, nextButtonNav, nextLink) {
nextButtonNav.removeClass('is-not-ready');
approveButton.addClass('approved'); approveButton.addClass('approved');
nextButton.removeClass('disabled'); nextLink.attr('href', "#next");
} }
function doSnapshotButton(captureButton, resetButton, approveButton) { function doSnapshotButton(captureButton, resetButton, approveButton) {
...@@ -67,9 +76,10 @@ function doSnapshotButton(captureButton, resetButton, approveButton) { ...@@ -67,9 +76,10 @@ function doSnapshotButton(captureButton, resetButton, approveButton) {
approveButton.show(); approveButton.show();
} }
function submitNameChange(event) { function submitNameChange(event) {
event.preventDefault(); event.preventDefault();
$("#lean_overlay").fadeOut(200);
$("#edit-name").css({ 'display' : 'none' });
var full_name = $('input[name="name"]').val(); var full_name = $('input[name="name"]').val();
var xhr = $.post( var xhr = $.post(
"/change_name", "/change_name",
...@@ -99,13 +109,15 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) { ...@@ -99,13 +109,15 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
var captureButton = $("#" + name + "_capture_button"); var captureButton = $("#" + name + "_capture_button");
var resetButton = $("#" + name + "_reset_button"); var resetButton = $("#" + name + "_reset_button");
var approveButton = $("#" + name + "_approve_button"); var approveButton = $("#" + name + "_approve_button");
var nextButton = $("#" + name + "_next_button"); var nextButtonNav = $("#" + name + "_next_button_nav");
var nextLink = $("#" + name + "_next_link");
var flashCapture = $("#" + name + "_flash"); var flashCapture = $("#" + name + "_flash");
var ctx = null; var ctx = null;
if (hasHtml5CameraSupport) { if (hasHtml5CameraSupport) {
ctx = canvas[0].getContext('2d'); ctx = canvas[0].getContext('2d');
} }
var localMediaStream = null; var localMediaStream = null;
function snapshot(event) { function snapshot(event) {
...@@ -120,8 +132,13 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) { ...@@ -120,8 +132,13 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
video[0].pause(); video[0].pause();
} }
else { else {
if (flashCapture[0].cameraAuthorized()) {
image[0].src = flashCapture[0].snap(); image[0].src = flashCapture[0].snap();
} }
else {
return false;
}
}
doSnapshotButton(captureButton, resetButton, approveButton); doSnapshotButton(captureButton, resetButton, approveButton);
return false; return false;
...@@ -137,12 +154,12 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) { ...@@ -137,12 +154,12 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
flashCapture[0].reset(); flashCapture[0].reset();
} }
doResetButton(resetButton, captureButton, approveButton, nextButton); doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink);
return false; return false;
} }
function approve() { function approve() {
doApproveButton(approveButton, nextButton) doApproveButton(approveButton, nextButtonNav, nextLink)
return false; return false;
} }
...@@ -150,7 +167,8 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) { ...@@ -150,7 +167,8 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
captureButton.show(); captureButton.show();
resetButton.hide(); resetButton.hide();
approveButton.hide(); approveButton.hide();
nextButton.addClass('disabled'); nextButtonNav.addClass('is-not-ready');
nextLink.attr('href', "#");
// Connect event handlers... // Connect event handlers...
video.click(snapshot); video.click(snapshot);
...@@ -178,18 +196,59 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) { ...@@ -178,18 +196,59 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
} }
function browserHasFlash() {
var hasFlash = false;
try {
var fo = new ActiveXObject('ShockwaveFlash.ShockwaveFlash');
if(fo) hasFlash = true;
} catch(e) {
if(navigator.mimeTypes["application/x-shockwave-flash"] != undefined) hasFlash = true;
}
return hasFlash;
}
function objectTagForFlashCamera(name) { function objectTagForFlashCamera(name) {
// detect whether or not flash is available
if(browserHasFlash()) {
// I manually update this to have ?v={2,3,4, etc} to avoid caching of flash
// objects on local dev.
return '<object type="application/x-shockwave-flash" id="' + return '<object type="application/x-shockwave-flash" id="' +
name + '" name="' + name + '" data=' + name + '" name="' + name + '" data=' +
'"/static/js/verify_student/CameraCapture.swf"' + '"/static/js/verify_student/CameraCapture.swf?v=3"' +
'width="500" height="375"><param name="quality" ' + 'width="500" height="375"><param name="quality" ' +
'value="high"><param name="allowscriptaccess" ' + 'value="high"><param name="allowscriptaccess" ' +
'value="sameDomain"></object>'; 'value="sameDomain"></object>';
}
else {
// display a message informing the user to install flash
$('#no-flash').show();
}
}
function linkNewWindow(e) {
window.open($(e.target).attr('href'));
e.preventDefault();
}
function waitForFlashLoad(func, flash_object) {
if(!flash_object.hasOwnProperty('percentLoaded') || flash_object.percentLoaded() < 100){
setTimeout(function() {
waitForFlashLoad(func, flash_object);
},
50);
}
else {
func(flash_object);
}
} }
$(document).ready(function() { $(document).ready(function() {
$(".carousel-nav").addClass('sr'); $(".carousel-nav").addClass('sr');
$("#pay_button").click(submitToPaymentProcessing); $("#pay_button").click(function(){
analytics.pageview("Payment Form");
submitToPaymentProcessing();
});
// prevent browsers from keeping this button checked // prevent browsers from keeping this button checked
$("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").prop("checked", false)
$("#confirm_pics_good").change(function() { $("#confirm_pics_good").change(function() {
...@@ -199,11 +258,13 @@ $(document).ready(function() { ...@@ -199,11 +258,13 @@ $(document).ready(function() {
// add in handlers to add/remove the correct classes to the body // add in handlers to add/remove the correct classes to the body
// when moving between steps // when moving between steps
$('#face_next_button').click(function(){ $('#face_next_link').click(function(){
analytics.pageview("Capture ID Photo");
$('body').addClass('step-photos-id').removeClass('step-photos-cam') $('body').addClass('step-photos-id').removeClass('step-photos-cam')
}) })
$('#photo_id_next_button').click(function(){ $('#photo_id_next_link').click(function(){
analytics.pageview("Review Photos");
$('body').addClass('step-review').removeClass('step-photos-id') $('body').addClass('step-review').removeClass('step-photos-id')
}) })
...@@ -217,8 +278,19 @@ $(document).ready(function() { ...@@ -217,8 +278,19 @@ $(document).ready(function() {
if (!hasHtml5CameraSupport) { if (!hasHtml5CameraSupport) {
$("#face_capture_div").html(objectTagForFlashCamera("face_flash")); $("#face_capture_div").html(objectTagForFlashCamera("face_flash"));
$("#photo_id_capture_div").html(objectTagForFlashCamera("photo_id_flash")); $("#photo_id_capture_div").html(objectTagForFlashCamera("photo_id_flash"));
// wait for the flash object to be loaded and then check for a camera
if(browserHasFlash()) {
waitForFlashLoad(function(flash_object) {
if(!flash_object.hasOwnProperty('hasCamera')){
onVideoFail('NO_DEVICES_FOUND');
}
}, $('#face_flash')[0]);
}
} }
analytics.pageview("Capture Face Photo");
initSnapshotHandler(["photo_id", "face"], hasHtml5CameraSupport); initSnapshotHandler(["photo_id", "face"], hasHtml5CameraSupport);
$('a[rel="external"]').attr('title', gettext('This link will open in a new browser window/tab')).bind('click', linkNewWindow);
}); });
...@@ -176,6 +176,9 @@ ...@@ -176,6 +176,9 @@
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
box-shadow: none; box-shadow: none;
:hover {
pointer-events: none;
}
} }
// ==================== // ====================
......
...@@ -218,10 +218,15 @@ ...@@ -218,10 +218,15 @@
// reset: forms // reset: forms
input { input,textarea {
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
margin-right: ($baseline/5); margin-right: ($baseline/5);
padding: ($baseline/4) ($baseline/2);
}
textarea {
padding: ($baseline/2);
} }
label { label {
...@@ -464,15 +469,11 @@ ...@@ -464,15 +469,11 @@
@include clearfix(); @include clearfix();
width: flex-grid(12,12); width: flex-grid(12,12);
.wrapper-sts, .sts-track { .sts-course, .sts-track {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }
.wrapper-sts {
width: flex-grid(9,12);
}
.sts-track { .sts-track {
width: flex-grid(3,12); width: flex-grid(3,12);
text-align: right; text-align: right;
...@@ -490,19 +491,36 @@ ...@@ -490,19 +491,36 @@
} }
} }
.sts { .sts-label {
@extend .t-title7; @extend .t-title7;
display: block; display: block;
margin-bottom: ($baseline/2);
border-bottom: ($baseline/10) solid $m-gray-l4;
padding-bottom: ($baseline/2);
color: $m-gray; color: $m-gray;
} }
.sts-course { .sts-course {
@extend .t-title; @extend .t-title;
width: flex-grid(9,12);
text-transform: none;
}
.sts-course-org, .sts-course-number {
@extend .t-title5;
@extend .t-weight4;
display: inline-block;
}
.sts-course-org {
margin-right: ($baseline/4);
}
.sts-course-name {
@include font-size(28); @include font-size(28);
@include line-height(28); @include line-height(28);
@extend .t-weight4; @extend .t-weight4;
display: block; display: block;
text-transform: none;
} }
} }
} }
...@@ -680,6 +698,7 @@ ...@@ -680,6 +698,7 @@
// help - general list // help - general list
.list-help { .list-help {
margin-top: ($baseline/2); margin-top: ($baseline/2);
color: $black;
.help-item { .help-item {
margin-bottom: ($baseline/4); margin-bottom: ($baseline/4);
...@@ -865,6 +884,7 @@ ...@@ -865,6 +884,7 @@
} }
.help-tips { .help-tips {
margin-left: $baseline;
.title { .title {
@extend .hd-lv5; @extend .hd-lv5;
...@@ -876,6 +896,7 @@ ...@@ -876,6 +896,7 @@
// help - general list // help - general list
.list-tips { .list-tips {
color: $black;
.tip { .tip {
margin-bottom: ($baseline/4); margin-bottom: ($baseline/4);
...@@ -1496,7 +1517,7 @@ ...@@ -1496,7 +1517,7 @@
border-color: $m-pink-l3; border-color: $m-pink-l3;
.title { .title {
@extend .t-title4; @extend .t-title5;
@extend .t-weight4; @extend .t-weight4;
border-bottom-color: $m-pink-l3; border-bottom-color: $m-pink-l3;
background: tint($m-pink, 95%); background: tint($m-pink, 95%);
...@@ -1615,6 +1636,27 @@ ...@@ -1615,6 +1636,27 @@
// VIEW: review photos // VIEW: review photos
&.step-review { &.step-review {
.modal.edit-name .submit input {
color: #fff;
}
.modal {
fieldset {
margin-top: $baseline;
}
.close-modal {
@include font-size(24);
color: $m-blue-d3;
&:hover {
color: $m-blue-d1;
border: none;
}
}
}
.nav-wizard { .nav-wizard {
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
${_("Hi {name}").format(name=order.user.profile.name)} ${_("Hi {name}").format(name=order.user.profile.name)}
${_("Your payment was successful. You will see the charge below on your next credit or debit card statement. The charge will show up on your statement under the company name {platform_name}. If you have billing questions, please read the FAQ or contact {billing_email}. We hope you enjoy your order.").format(platform_name=settings.PLATFORM_NAME,billing_email=settings.PAYMENT_SUPPORT_EMAIL)} ${_("Your payment was successful. You will see the charge below on your next credit or debit card statement. The charge will show up on your statement under the company name {platform_name}. If you have billing questions, please read the FAQ ({faq_url}) or contact {billing_email}.").format(platform_name=settings.PLATFORM_NAME, billing_email=settings.PAYMENT_SUPPORT_EMAIL, faq_url=marketing_link('FAQ'))}
${_("-The {platform_name} Team").format(platform_name=settings.PLATFORM_NAME)} ${_("-The {platform_name} Team").format(platform_name=settings.PLATFORM_NAME)}
...@@ -11,9 +11,9 @@ ${_("The items in your order are:")} ...@@ -11,9 +11,9 @@ ${_("The items in your order are:")}
${_("Quantity - Description - Price")} ${_("Quantity - Description - Price")}
%for order_item in order_items: %for order_item in order_items:
${order_item.qty} - ${order_item.line_desc} - ${order_item.line_cost} ${order_item.qty} - ${order_item.line_desc} - ${"$" if order_item.currency == 'usd' else ""}${order_item.line_cost}
%endfor %endfor
${_("Total billed to credit/debit card: {total_cost}").format(total_cost=order.total_cost)} ${_("Total billed to credit/debit card: {currency_symbol}{total_cost}").format(total_cost=order.total_cost, currency_symbol=("$" if order.currency == 'usd' else ""))}
%for order_item in order_items: %for order_item in order_items:
${order_item.additional_instruction_text} ${order_item.additional_instruction_text}
......
...@@ -9,6 +9,4 @@ ...@@ -9,6 +9,4 @@
<section class="container"> <section class="container">
<p><h1>${_("There was an error processing your order!")}</h1></p> <p><h1>${_("There was an error processing your order!")}</h1></p>
${error_html} ${error_html}
<p><a href="${reverse('shoppingcart.views.show_cart')}">${_("Return to cart to retry payment")}</a></p>
</section> </section>
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from student.views import course_from_id %> <%! from student.views import course_from_id %>
<%! from datetime import datetime %>
<%! import pytz %>
<%inherit file="../main.html" /> <%inherit file="../main.html" />
<%block name="bodyclass">register verification-process step-confirmation</%block> <%block name="bodyclass">register verification-process step-confirmation</%block>
...@@ -15,26 +13,29 @@ ...@@ -15,26 +13,29 @@
${notification} ${notification}
</section> </section>
% endif % endif
<% course_id = order_items[0].course_id %>
<% course = course_from_id(course_id) %>
<div class="container"> <div class="container">
<section class="wrapper cart-list"> <section class="wrapper cart-list">
<header class="page-header"> <header class="page-header">
<h2 class="title"> <h2 class="title">
<span class="sts-label">${_("You are now registered for: ")}</span>
<span class="wrapper-sts"> <span class="wrapper-sts">
<span class="sts">${_("You are now registered for")}</span> <span class="sts-course">
<span class="sts-course">${course.display_name}</span> <span class="sts-course-org">${course_org}</span>
<span class="sts-course-number">${course_num}</span>
<span class="sts-course-name">${course_name}</span>
</span> </span>
<span class="sts-track"> <span class="sts-track">
<span class="sts-track-value"> <span class="sts-track-value">
<span class="context">${_("Registered as: ")}</span> ${_("ID Verified")} <span class="context">${_("Registering as: ")}</span> ${_("ID Verified")}
</span>
</span> </span>
</span> </span>
</h2> </h2>
</header> </header>
<div class="wrapper-progress"> <div class="wrapper-progress">
<section class="progress"> <section class="progress">
...@@ -108,11 +109,11 @@ ...@@ -108,11 +109,11 @@
<tr> <tr>
<td>${item.line_desc}</td> <td>${item.line_desc}</td>
<td> <td>
${_("Starts: {start_date}").format(start_date=course.start_date_text)} ${_("Starts: {start_date}").format(start_date=course_start_date_text)}
</td> </td>
<td class="options"> <td class="options">
%if course.start > datetime.today().replace(tzinfo=pytz.utc): %if course_has_started:
${_("Starts: {start_date}").format(start_date=course.start_date_text)} ${_("Starts: {start_date}").format(start_date=course_start_date_text)}
%else: %else:
<a class="action action-course" href="${reverse('course_root', kwargs={'course_id': item.course_id})}">${_("Go to Course")}</a> <a class="action action-course" href="${reverse('course_root', kwargs={'course_id': item.course_id})}">${_("Go to Course")}</a>
%endif %endif
...@@ -198,8 +199,15 @@ ...@@ -198,8 +199,15 @@
</div> </div>
% endif % endif
</div> </div>
<div class="copy">
<p>${_("Billed To")}:
<span class="name-first">${order.bill_to_first}</span> <span class="name-last">${order.bill_to_last}</span> (<span class="address-city">${order.bill_to_city}</span>, <span class="address-state">${order.bill_to_state}</span> <span class="address-postalcode">${order.bill_to_postalcode}</span> <span class="address-country">${order.bill_to_country.upper()}</span>)
</p>
</div>
</li> </li>
<%doc>
<li class="info-item billing-info"> <li class="info-item billing-info">
<h4 class="title">${_("Billing Information")}</h4> <h4 class="title">${_("Billing Information")}</h4>
...@@ -249,6 +257,7 @@ ...@@ -249,6 +257,7 @@
</table> </table>
</div> </div>
</li> </li>
</%doc>
</ul> </ul>
</article> </article>
</div> </div>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<section id="edit-name" class="modal"> <section id="edit-name" class="modal">
<div class="inner-wrapper">
<header> <header>
<h4>${_("Edit Your Full Name")}</h4> <h2>${_("Edit Your Name")}</h2>
</header> <hr />
</header>
<div id="change_name_body">
<form id="course-checklists" class="course-checklists" method="post" action=""> <form id="course-checklists" class="course-checklists" method="post" action="">
<div role="alert" class="status message submission-error" tabindex="-1"> <div role="alert" class="status message submission-error" tabindex="-1">
<p class="message-title">${_("The following error occured while editing your name:")} <p class="message-title">${_("The following error occured while editing your name:")}
<span class="message-copy"> </span> <span class="message-copy"> </span>
</p> </p>
</div> </div>
<p> <p>${_("To uphold the credibility of {platform} certificates, all name changes will be logged and recorded.").format(platform=settings.PLATFORM_NAME)}</p>
<fieldset>
<div class="input-group">
<label for="name">${_('Full Name')}</label> <label for="name">${_('Full Name')}</label>
<input id="name" type="text" name="name" value="" placeholder="${user_full_name}" required aria-required="true" /> <input id="name" type="text" name="name" value="" placeholder="${user_full_name}" required aria-required="true" />
</p> <label>${_("Reason for name change:")}</label>
<textarea id="name_rationale_field" value=""></textarea>
</div>
<div class="actions"> <div class="actions">
<button class="action action-primary action-save">${_("Save")}</button> <button id="submit" class="action action-primary action-save">${_("Change my name")}</button>
</div> </div>
</form> </form>
<a href="#" data-dismiss="leanModal" rel="view" class="action action-close action-editname-close"> </div>
<a href="javascript:void(0)" data-dismiss="leanModal" rel="view" class="action action-close action-editname-close close-modal">
<i class="icon-remove-sign"></i> <i class="icon-remove-sign"></i>
<span class="label">${_("close")}</span> <span class="sr">${_("close")}</span>
</a> </a>
</section> </section>
...@@ -2,14 +2,20 @@ ...@@ -2,14 +2,20 @@
<header class="page-header"> <header class="page-header">
<h2 class="title"> <h2 class="title">
<span class="sts-label">${_("You are registering for")}</span>
<span class="wrapper-sts"> <span class="wrapper-sts">
<span class="sts">${_("You are registering for")}</span> <span class="sts-course">
<span class="sts-course">${course_name}</span> <span class="sts-course-org">${course_org}</span>
<span class="sts-course-number">${course_num}</span>
<span class="sts-course-name">${course_name}</span>
</span> </span>
<span class="sts-track"> <span class="sts-track">
<span class="sts-track-value"> <span class="sts-track-value">
<span class="context">${_("Registering as: ")}</span> ${_("ID Verified")} <span class="context">${_("Registering as: ")}</span> ${_("ID Verified")}
</span> </span>
</span> </span>
</span>
</h2> </h2>
</header> </header>
...@@ -13,6 +13,32 @@ ...@@ -13,6 +13,32 @@
</%block> </%block>
<%block name="content"> <%block name="content">
<div id="no-webcam" style="display: none;" class="wrapper-msg wrapper-msg-activate">
<div class=" msg msg-activate">
<i class="msg-icon icon-warning-sign"></i>
<div class="msg-content">
<h3 class="title">${_("No Webcam Detected")}</h3>
<div class="copy">
<p>${_("You don't seem to have a webcam connected. Double-check that your webcam is connected and working to continue registering, or select to {a_start} audit the course for free {a_end} without verifying.").format(a_start='<a rel="external" href="/course_modes/choose/' + course_id + '">', a_end="</a>")}</p>
</div>
</div>
</div>
</div>
<div id="no-flash" style="display: none;" class="wrapper-msg wrapper-msg-activate">
<div class=" msg msg-activate">
<i class="msg-icon icon-warning-sign"></i>
<div class="msg-content">
<h3 class="title">${_("No Flash Detected")}</h3>
<div class="copy">
<p>${_("You don't seem to have Flash installed. {a_start} Get Flash {a_end} to continue your registration.").format(a_start='<a rel="external" href="http://get.adobe.com/flashplayer/">', a_end="</a>")}</p>
</div>
</div>
</div>
</div>
<div class="container"> <div class="container">
<section class="wrapper"> <section class="wrapper">
...@@ -79,7 +105,7 @@ ...@@ -79,7 +105,7 @@
<div class="placeholder-cam" id="face_capture_div"> <div class="placeholder-cam" id="face_capture_div">
<div class="placeholder-art"> <div class="placeholder-art">
<p class="copy">${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission. <br />Still not working? You can {a_start} audit the course {a_end} without verifying.").format(a_start='<a rel="external" href="/course_modes/choose/' + course_id + '">', a_end="</a>")}</p> <p class="copy">${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission.")}</p>
</div> </div>
<video id="face_video" autoplay></video><br/> <video id="face_video" autoplay></video><br/>
...@@ -133,18 +159,20 @@ ...@@ -133,18 +159,20 @@
<dt class="faq-question">${_("What do you do with this picture?")}</dt> <dt class="faq-question">${_("What do you do with this picture?")}</dt>
<dd class="faq-answer">${_("We only use it to verify your identity. It is not displayed anywhere.")}</dd> <dd class="faq-answer">${_("We only use it to verify your identity. It is not displayed anywhere.")}</dd>
<dt class="faq-question">${_("What if my camera isn't working?")}</dt>
<dd class="faq-answer">${_("You can always {a_start} audit the course for free {a_end} without verifying.").format(a_start='<a rel="external" href="/course_modes/choose/' + course_id + '">', a_end="</a>")}</dd>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<nav class="nav-wizard"> <!-- FIXME: Additional class is-ready, is-not-ready --> <nav class="nav-wizard" id="face_next_button_nav">
<span class="help help-inline">${_("Once you verify your photo looks good, you can move on to step 2.")}</span> <span class="help help-inline">${_("Once you verify your photo looks good, you can move on to step 2.")}</span>
<ol class="wizard-steps"> <ol class="wizard-steps">
<li class="wizard-step"> <li class="wizard-step">
<a class="next action-primary" id="face_next_button" href="#next" aria-hidden="true" title="Next">${_("Go to Step 2: Take ID Photo")}</a> <a id="face_next_link" class="next action-primary" href="#next" aria-hidden="true" title="Next">${_("Go to Step 2: Take ID Photo")}</a>
</li> </li>
</ol> </ol>
</nav> </nav>
...@@ -164,7 +192,7 @@ ...@@ -164,7 +192,7 @@
<div class="placeholder-cam" id="photo_id_capture_div"> <div class="placeholder-cam" id="photo_id_capture_div">
<div class="placeholder-art"> <div class="placeholder-art">
<p class="copy">${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission. Still not working? You can {a_start} audit the course {a_end} without verifying.").format(a_start='<a rel="external" href="/course_modes/choose/' + course_id + '">', a_end="</a>")}</p> <p class="copy">${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission.")}</p>
</div> </div>
<video id="photo_id_video" autoplay></video><br/> <video id="photo_id_video" autoplay></video><br/>
...@@ -226,12 +254,12 @@ ...@@ -226,12 +254,12 @@
</div> </div>
</div> </div>
<nav class="nav-wizard"> <nav class="nav-wizard" id="photo_id_next_button_nav">
<span class="help help-inline">${_("Once you verify your ID photo looks good, you can move on to step 3.")}</span> <span class="help help-inline">${_("Once you verify your ID photo looks good, you can move on to step 3.")}</span>
<ol class="wizard-steps"> <ol class="wizard-steps">
<li class="wizard-step"> <li class="wizard-step">
<a class="next action-primary" id="photo_id_next_button" href="#next" aria-hidden="true" title="Next">${_("Go to Step 3: Review Your Info")}</a> <a id="photo_id_next_link" class="next action-primary" href="#next" aria-hidden="true" title="Next">${_("Go to Step 3: Review Your Info")}</a>
</li> </li>
</ol> </ol>
</nav> </nav>
...@@ -247,19 +275,6 @@ ...@@ -247,19 +275,6 @@
<div class="wrapper-task"> <div class="wrapper-task">
<ol class="review-tasks"> <ol class="review-tasks">
<li class="review-task review-task-name">
<h4 class="title">${_("Check Your Name")}</h4>
<div class="copy">
<p>${_("Make sure your full name on your edX account ({full_name}) matches your ID. We will also use this as the name on your certificate.").format(full_name="<span id='full-name'>" + user_full_name + "</span>")}</p>
</div>
<ul class="list-actions">
<li class="action action-editname">
<a class="edit-name" rel="leanModal" href="#edit-name">${_("Edit your name")}</a>
</li>
</ul>
</li>
<li class="review-task review-task-photos"> <li class="review-task review-task-photos">
<h4 class="title">${_("Review the Photos You've Taken")}</h4> <h4 class="title">${_("Review the Photos You've Taken")}</h4>
...@@ -313,6 +328,20 @@ ...@@ -313,6 +328,20 @@
</div> </div>
</li> </li>
<li class="review-task review-task-name">
<h4 class="title">${_("Check Your Name")}</h4>
<div class="copy">
<p>${_("Make sure your full name on your edX account ({full_name}) matches your ID. We will also use this as the name on your certificate.").format(full_name="<span id='full-name'>" + user_full_name + "</span>")}</p>
</div>
<ul class="list-actions">
<li class="action action-editname">
<a class="edit-name" rel="leanModal" href="#edit-name">${_("Edit your name")}</a>
</li>
</ul>
</li>
<li class="review-task review-task-contribution"> <li class="review-task review-task-contribution">
<h4 class="title">${_("Check Your Contribution Level")}</h4> <h4 class="title">${_("Check Your Contribution Level")}</h4>
......
...@@ -87,7 +87,7 @@ ...@@ -87,7 +87,7 @@
<div class="copy"> <div class="copy">
<p> <p>
<span class="copy-super">${_("Check Your Email")}</span> <span class="copy-super">${_("Check your email")}</span>
<span class="copy-sub">${_("you need an active edX account before registering - check your email for instructions")}</span> <span class="copy-sub">${_("you need an active edX account before registering - check your email for instructions")}</span>
</p> </p>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment