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
This commit is contained in:
@@ -55,7 +55,11 @@ class CourseMode(models.Model):
|
||||
|
||||
@classmethod
|
||||
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
|
||||
def mode_for_course(cls, course_id, mode_slug):
|
||||
|
||||
@@ -25,11 +25,18 @@ class ChooseModeView(View):
|
||||
if CourseEnrollment.enrollment_mode_for_user(request.user, course_id) == 'verified':
|
||||
return redirect(reverse('dashboard'))
|
||||
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 = {
|
||||
"course_id": course_id,
|
||||
"modes": modes,
|
||||
"course_name": course_from_id(course_id).display_name,
|
||||
"chosen_price": None,
|
||||
"course_name": course.display_name_with_default,
|
||||
"course_org" : course.display_org_with_default,
|
||||
"course_num" : course.display_number_with_default,
|
||||
"chosen_price": chosen_price,
|
||||
"error": error,
|
||||
}
|
||||
if "verified" in modes:
|
||||
|
||||
@@ -183,7 +183,7 @@ class WeightedSubsectionsGrader(CourseGrader):
|
||||
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
|
||||
|
||||
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
|
||||
section_breakdown += subgrade_result['section_breakdown']
|
||||
@@ -224,14 +224,16 @@ class SingleSectionGrader(CourseGrader):
|
||||
possible = found_score.possible
|
||||
|
||||
percent = earned / float(possible)
|
||||
detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name,
|
||||
percent=percent,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
detail = u"{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(
|
||||
name=self.name,
|
||||
percent=percent,
|
||||
earned=float(earned),
|
||||
possible=float(possible)
|
||||
)
|
||||
|
||||
else:
|
||||
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,
|
||||
'detail': detail, 'category': self.category, 'prominent': True}]
|
||||
@@ -323,20 +325,26 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
section_name = scores[i].section
|
||||
|
||||
percentage = earned / float(possible)
|
||||
summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})"
|
||||
summary = summary_format.format(index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
name=section_name,
|
||||
percent=percentage,
|
||||
earned=float(earned),
|
||||
possible=float(possible))
|
||||
summary_format = u"{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})"
|
||||
summary = summary_format.format(
|
||||
index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
name=section_name,
|
||||
percent=percentage,
|
||||
earned=float(earned),
|
||||
possible=float(possible)
|
||||
)
|
||||
else:
|
||||
percentage = 0
|
||||
summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index,
|
||||
section_type=self.section_type)
|
||||
summary = u"{section_type} {index} Unreleased - 0% (?/?)".format(
|
||||
index=i + self.starting_index,
|
||||
section_type=self.section_type
|
||||
)
|
||||
|
||||
short_label = "{short_label} {index:02d}".format(index=i + self.starting_index,
|
||||
short_label=self.short_label)
|
||||
short_label = u"{short_label} {index:02d}".format(
|
||||
index=i + self.starting_index,
|
||||
short_label=self.short_label
|
||||
)
|
||||
|
||||
breakdown.append({'percent': percentage, 'label': short_label,
|
||||
'detail': summary, 'category': self.category})
|
||||
@@ -344,22 +352,24 @@ class AssignmentFormatGrader(CourseGrader):
|
||||
total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count)
|
||||
|
||||
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)}
|
||||
|
||||
if len(breakdown) == 1:
|
||||
# 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
|
||||
# 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)
|
||||
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,
|
||||
'detail': total_detail, 'category': self.category, 'prominent': True}, ]
|
||||
else:
|
||||
total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent,
|
||||
section_type=self.section_type)
|
||||
total_label = "{short_label} Avg".format(short_label=self.short_label)
|
||||
total_detail = u"{section_type} Average = {percent:.0%}".format(
|
||||
percent=total_percent,
|
||||
section_type=self.section_type
|
||||
)
|
||||
total_label = u"{short_label} Avg".format(short_label=self.short_label)
|
||||
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
|
||||
@@ -128,7 +128,7 @@ $(document).ready(function() {
|
||||
|
||||
<h3 class="title">${_("What is an ID Verified Certificate?")}</h3>
|
||||
<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>
|
||||
% endif
|
||||
|
||||
@@ -28,9 +28,6 @@ Feature: Verified certificates
|
||||
When I submit valid payment information
|
||||
Then I see that my payment was successful
|
||||
|
||||
|
||||
# Not yet implemented LMS-982
|
||||
@skip
|
||||
Scenario: Verified courses display correctly on dashboard
|
||||
Given I have submitted photos to verify my identity
|
||||
When I submit valid payment information
|
||||
@@ -57,8 +54,6 @@ Feature: Verified certificates
|
||||
When I edit my name
|
||||
Then I see the new name on the confirmation page.
|
||||
|
||||
# Currently broken LMS-1009
|
||||
@skip
|
||||
Scenario: I can return to the verify flow
|
||||
Given I have submitted photos to verify my identity
|
||||
When I leave the flow and return
|
||||
@@ -72,9 +67,8 @@ Feature: Verified certificates
|
||||
And I press the payment button
|
||||
Then I am at the payment page
|
||||
|
||||
# Design not yet finalized
|
||||
@skip
|
||||
Scenario: I can take a verified certificate course for free
|
||||
Given I have submitted photos to verify my identity
|
||||
When I give a reason why I cannot pay
|
||||
Then I see that I am registered for a verified certificate course on my dashboard
|
||||
Given I am logged in
|
||||
And the course has an honor mode
|
||||
When I give a reason why I cannot pay
|
||||
Then I should see the course on my dashboard
|
||||
|
||||
@@ -13,6 +13,7 @@ def create_cert_course():
|
||||
name = 'Certificates'
|
||||
course_id = '{org}/{number}/{name}'.format(
|
||||
org=org, number=number, name=name)
|
||||
world.scenario_dict['course_id'] = course_id
|
||||
world.scenario_dict['COURSE'] = world.CourseFactory.create(
|
||||
org=org, number=number, display_name=name)
|
||||
|
||||
@@ -44,6 +45,18 @@ def register():
|
||||
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$')
|
||||
def select_the_audit_track(step):
|
||||
create_cert_course()
|
||||
@@ -80,8 +93,8 @@ def should_see_the_course_on_my_dashboard(step):
|
||||
def goto_next_step(step, step_num):
|
||||
btn_css = {
|
||||
'1': '#face_next_button',
|
||||
'2': '#face_next_button',
|
||||
'3': '#photo_id_next_button',
|
||||
'2': '#face_next_link',
|
||||
'3': '#photo_id_next_link',
|
||||
'4': '#pay_button',
|
||||
}
|
||||
next_css = {
|
||||
@@ -100,15 +113,9 @@ def goto_next_step(step, step_num):
|
||||
@step(u'I capture my "([^"]*)" photo$')
|
||||
def capture_my_photo(step, name):
|
||||
|
||||
# Draw a red rectangle in the image element
|
||||
snapshot_script = '"{}{}{}{}{}{}"'.format(
|
||||
"var canvas = $('#{}_canvas');".format(name),
|
||||
"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');"
|
||||
)
|
||||
# Hard coded red dot image
|
||||
image_data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
|
||||
snapshot_script = "$('#{}_image')[0].src = '{}';".format(name, image_data)
|
||||
|
||||
# Mirror the javascript of the photo_verification.html page
|
||||
world.browser.execute_script(snapshot_script)
|
||||
@@ -171,8 +178,8 @@ def submit_payment(step):
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I have submitted photos to verify my identity')
|
||||
def submitted_photos_to_verify_my_identity(step):
|
||||
@step(u'I have submitted face and ID photos$')
|
||||
def submitted_face_and_id_photos(step):
|
||||
step.given('I am logged in')
|
||||
step.given('I select the verified track')
|
||||
step.given('I go to step "1"')
|
||||
@@ -182,6 +189,11 @@ def submitted_photos_to_verify_my_identity(step):
|
||||
step.given('I capture my "photo_id" photo')
|
||||
step.given('I approve my "photo_id" photo')
|
||||
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 confirm that the details match')
|
||||
step.given('I go to step "4"')
|
||||
@@ -207,14 +219,38 @@ def see_the_course_on_my_dashboard(step):
|
||||
|
||||
@step(u'I see that I am on the verified track')
|
||||
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$')
|
||||
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$')
|
||||
def see_the_payment_page(step):
|
||||
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):
|
||||
def get_dynamic_descriptor_children(descriptor):
|
||||
if descriptor.has_dynamic_children():
|
||||
module = module_creator(descriptor)
|
||||
if module is None:
|
||||
return []
|
||||
return module.get_child_descriptors()
|
||||
else:
|
||||
return descriptor.get_children()
|
||||
|
||||
@@ -19,6 +19,7 @@ from mitxmako.shortcuts import render_to_string
|
||||
from student.views import course_from_id
|
||||
from student.models import CourseEnrollment
|
||||
from dogapi import dog_stats_api
|
||||
from verify_student.models import SoftwareSecurePhotoVerification
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
@@ -371,6 +372,14 @@ class CertificateItem(OrderItem):
|
||||
"""
|
||||
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.save()
|
||||
self.course_enrollment.activate()
|
||||
@@ -382,9 +391,22 @@ class CertificateItem(OrderItem):
|
||||
else:
|
||||
return super(CertificateItem, self).single_item_receipt_template
|
||||
|
||||
@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):
|
||||
return textwrap.dedent(
|
||||
_("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}.").format(
|
||||
billing_email=settings.PAYMENT_SUPPORT_EMAIL))
|
||||
return _("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}. "
|
||||
"Please include your order number in your e-mail. "
|
||||
"Please do NOT include your credit card information.").format(
|
||||
billing_email=settings.PAYMENT_SUPPORT_EMAIL)
|
||||
|
||||
@@ -113,5 +113,6 @@ def show_receipt(request, ordernum):
|
||||
|
||||
if order_items.count() == 1:
|
||||
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)
|
||||
|
||||
4
lms/djangoapps/verify_student/admin.py
Normal file
4
lms/djangoapps/verify_student/admin.py
Normal file
@@ -0,0 +1,4 @@
|
||||
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
|
||||
photo verification process as generic as possible.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from email.utils import formatdate
|
||||
from hashlib import md5
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from boto.s3.connection import S3Connection
|
||||
from boto.s3.key import Key
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from model_utils.models import StatusModel
|
||||
from model_utils import Choices
|
||||
|
||||
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__)
|
||||
@@ -86,6 +94,9 @@ class PhotoVerification(StatusModel):
|
||||
`submitted`
|
||||
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.
|
||||
`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`
|
||||
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.
|
||||
@@ -106,7 +117,7 @@ class PhotoVerification(StatusModel):
|
||||
|
||||
######################## Fields Set During Creation ########################
|
||||
# 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)
|
||||
|
||||
# They can change their name later on, so we want to copy the value here so
|
||||
@@ -183,7 +194,7 @@ class PhotoVerification(StatusModel):
|
||||
"""
|
||||
TODO: eliminate duplication with user_is_verified
|
||||
"""
|
||||
valid_statuses = ['ready', 'submitted', 'approved']
|
||||
valid_statuses = ['must_retry', 'submitted', 'approved']
|
||||
earliest_allowed_date = (
|
||||
earliest_allowed_date or
|
||||
datetime.now(pytz.UTC) - timedelta(days=cls.DAYS_GOOD_FOR)
|
||||
@@ -205,7 +216,7 @@ class PhotoVerification(StatusModel):
|
||||
"""
|
||||
# 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.
|
||||
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:
|
||||
return active_attempts[0]
|
||||
else:
|
||||
@@ -246,10 +257,10 @@ class PhotoVerification(StatusModel):
|
||||
they uploaded are good. Note that we don't actually do a submission
|
||||
anywhere yet.
|
||||
"""
|
||||
if not self.face_image_url:
|
||||
raise VerificationException("No face image was uploaded.")
|
||||
if not self.photo_id_image_url:
|
||||
raise VerificationException("No photo ID image was uploaded.")
|
||||
# if not self.face_image_url:
|
||||
# raise VerificationException("No face image was uploaded.")
|
||||
# if not self.photo_id_image_url:
|
||||
# raise VerificationException("No photo ID image was uploaded.")
|
||||
|
||||
# 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
|
||||
@@ -258,18 +269,11 @@ class PhotoVerification(StatusModel):
|
||||
self.status = "ready"
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("ready", "submit")
|
||||
def submit(self, reviewing_service=None):
|
||||
if self.status == "submitted":
|
||||
return
|
||||
@status_before_must_be("must_retry", "ready", "submitted")
|
||||
def submit(self):
|
||||
raise NotImplementedError
|
||||
|
||||
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=""):
|
||||
"""
|
||||
Approve this attempt. `user_id`
|
||||
@@ -309,7 +313,7 @@ class PhotoVerification(StatusModel):
|
||||
self.status = "approved"
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("submitted", "approved", "denied")
|
||||
@status_before_must_be("must_retry", "submitted", "approved", "denied")
|
||||
def deny(self,
|
||||
error_msg,
|
||||
error_code="",
|
||||
@@ -384,25 +388,133 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
# encode that. The result is saved here. Actual expected length is 344.
|
||||
photo_id_key = models.TextField(max_length=1024)
|
||||
|
||||
IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds
|
||||
|
||||
@status_before_must_be("created")
|
||||
def upload_face_image(self, img_data):
|
||||
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
|
||||
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")
|
||||
def upload_photo_id_image(self, img_data):
|
||||
aes_key = random_aes_key()
|
||||
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
|
||||
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
|
||||
rsa_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
|
||||
rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str)
|
||||
|
||||
# Upload this to S3
|
||||
s3_key = self._generate_key("photo_id")
|
||||
s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key))
|
||||
|
||||
rsa_key = RSA.importKey(
|
||||
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
|
||||
# Update our record fields
|
||||
self.photo_id_key = rsa_encrypted_aes_key.encode('base64')
|
||||
self.save()
|
||||
|
||||
@status_before_must_be("must_retry", "ready", "submitted")
|
||||
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"]
|
||||
)
|
||||
rsa_cipher = PKCS1_OAEP.new(key)
|
||||
rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_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')
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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 binascii
|
||||
import json
|
||||
import hmac
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from Crypto import Random
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def encrypt_and_encode(data, key):
|
||||
return base64.urlsafe_b64encode(aes_encrypt(data, key))
|
||||
@@ -88,3 +97,71 @@ def rsa_decrypt(data, rsa_priv_key_str):
|
||||
key = RSA.importKey(rsa_priv_key_str)
|
||||
cipher = PKCS1_OAEP.new(key)
|
||||
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):
|
||||
assert_equals(attempt.status, SoftwareSecurePhotoVerification.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.
|
||||
assert_raises(VerificationException, attempt.submit)
|
||||
assert_raises(VerificationException, attempt.approve)
|
||||
@@ -47,14 +44,14 @@ class TestPhotoVerification(TestCase):
|
||||
assert_raises(VerificationException, attempt.deny)
|
||||
|
||||
# Now we submit
|
||||
attempt.submit()
|
||||
assert_equals(attempt.status, "submitted")
|
||||
#attempt.submit()
|
||||
#assert_equals(attempt.status, "submitted")
|
||||
|
||||
# So we should be able to both approve and deny
|
||||
attempt.approve()
|
||||
assert_equals(attempt.status, "approved")
|
||||
#attempt.approve()
|
||||
#assert_equals(attempt.status, "approved")
|
||||
|
||||
attempt.deny("Could not read name on Photo ID")
|
||||
assert_equals(attempt.status, "denied")
|
||||
#attempt.deny("Could not read name on Photo ID")
|
||||
#assert_equals(attempt.status, "denied")
|
||||
|
||||
|
||||
|
||||
@@ -29,10 +29,17 @@ urlpatterns = patterns(
|
||||
name="verify_student_create_order"
|
||||
),
|
||||
|
||||
url(
|
||||
r'^results_callback$',
|
||||
views.results_callback,
|
||||
name="verify_student_results_callback",
|
||||
),
|
||||
|
||||
url(
|
||||
r'^show_verification_page/(?P<course_id>[^/]+/[^/]+/[^/]+)$',
|
||||
views.show_verification_page,
|
||||
name="verify_student/show_verification_page"
|
||||
),
|
||||
|
||||
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
||||
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 _
|
||||
@@ -26,6 +28,7 @@ from shoppingcart.processors.CyberSource import (
|
||||
get_signed_purchase_params, get_purchase_endpoint
|
||||
)
|
||||
from verify_student.models import SoftwareSecurePhotoVerification
|
||||
import ssencrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,11 +58,15 @@ class VerifyView(View):
|
||||
chosen_price = request.session["donation_for_course"][course_id]
|
||||
else:
|
||||
chosen_price = verify_mode.min_price
|
||||
|
||||
course = course_from_id(course_id)
|
||||
context = {
|
||||
"progress_state": progress_state,
|
||||
"user_full_name": request.user.profile.name,
|
||||
"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(),
|
||||
"suggested_prices": [
|
||||
decimal.Decimal(price)
|
||||
@@ -91,9 +98,12 @@ class VerifiedView(View):
|
||||
else:
|
||||
chosen_price = verify_mode.min_price.format("{:g}")
|
||||
|
||||
course = course_from_id(course_id)
|
||||
context = {
|
||||
"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(),
|
||||
"currency": verify_mode.currency.upper(),
|
||||
"chosen_price": chosen_price,
|
||||
@@ -108,7 +118,13 @@ def create_order(request):
|
||||
"""
|
||||
if not SoftwareSecurePhotoVerification.user_has_valid_or_pending(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()
|
||||
|
||||
course_id = request.POST['course_id']
|
||||
@@ -142,6 +158,45 @@ def create_order(request):
|
||||
|
||||
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
|
||||
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':
|
||||
return redirect(reverse('dashboard'))
|
||||
|
||||
course = course_from_id(course_id)
|
||||
context = {
|
||||
"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,
|
||||
"course_name": course_from_id(course_id).display_name,
|
||||
}
|
||||
return render_to_response("verify_student/show_requirements.html", context)
|
||||
|
||||
@@ -161,7 +220,6 @@ def show_requirements(request, course_id):
|
||||
def show_verification_page(request):
|
||||
pass
|
||||
|
||||
|
||||
def enroll(user, course_id, mode_slug):
|
||||
"""
|
||||
Enroll the user in a course for a certain mode.
|
||||
@@ -203,7 +261,6 @@ def enroll(user, course_id, mode_slug):
|
||||
# Create a VerifiedCertificate order item
|
||||
return HttpResponse.Redirect(reverse('verified'))
|
||||
|
||||
|
||||
# There's always at least one mode available (default is "honor"). If they
|
||||
# haven't specified a mode, we just assume it's
|
||||
if not mode:
|
||||
|
||||
@@ -262,3 +262,6 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
|
||||
|
||||
# Event tracking
|
||||
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
|
||||
TEMPLATE_DEBUG = True
|
||||
|
||||
|
||||
MITX_FEATURES['DISABLE_START_DATES'] = True
|
||||
MITX_FEATURES['DISABLE_START_DATES'] = False
|
||||
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_BRANDING'] = True
|
||||
|
||||
@@ -18,11 +18,13 @@ package
|
||||
import flash.display.PNGEncoderOptions;
|
||||
import flash.display.Sprite;
|
||||
import flash.events.Event;
|
||||
import flash.events.StatusEvent;
|
||||
import flash.external.ExternalInterface;
|
||||
import flash.geom.Rectangle;
|
||||
import flash.media.Camera;
|
||||
import flash.media.Video;
|
||||
import flash.utils.ByteArray;
|
||||
|
||||
import mx.utils.Base64Encoder;
|
||||
|
||||
[SWF(width="640", height="480")]
|
||||
@@ -35,15 +37,17 @@ package
|
||||
private var camera:Camera;
|
||||
private var video:Video;
|
||||
private var b64EncodedImage:String = null;
|
||||
private var permissionGiven:Boolean = false;
|
||||
|
||||
public function CameraCapture()
|
||||
{
|
||||
addEventListener(Event.ADDED_TO_STAGE, init);
|
||||
addEventListener(Event.ADDED_TO_STAGE, init);
|
||||
}
|
||||
|
||||
protected function init(e:Event):void {
|
||||
camera = Camera.getCamera();
|
||||
camera.setMode(VIDEO_WIDTH, VIDEO_HEIGHT, 30);
|
||||
camera.addEventListener(StatusEvent.STATUS, statusHandler);
|
||||
|
||||
video = new Video(VIDEO_WIDTH, VIDEO_HEIGHT);
|
||||
video.attachCamera(camera);
|
||||
@@ -53,6 +57,8 @@ package
|
||||
ExternalInterface.addCallback("snap", snap);
|
||||
ExternalInterface.addCallback("reset", reset);
|
||||
ExternalInterface.addCallback("imageDataUrl", imageDataUrl);
|
||||
ExternalInterface.addCallback("cameraAuthorized", cameraAuthorized);
|
||||
ExternalInterface.addCallback("hasCamera", hasCamera);
|
||||
|
||||
// Notify the container that the SWF is ready to be called.
|
||||
ExternalInterface.call("setSWFIsReady");
|
||||
@@ -98,6 +104,28 @@ package
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,10 @@
|
||||
var onVideoFail = function(e) {
|
||||
console.log('Failed to get camera access!', e);
|
||||
if(e == 'NO_DEVICES_FOUND') {
|
||||
$('#no-webcam').show();
|
||||
}
|
||||
else {
|
||||
console.log('Failed to get camera access!', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns true if we are capable of video capture (regardless of whether the
|
||||
@@ -27,7 +32,9 @@ var submitToPaymentProcessing = function() {
|
||||
"/verify_student/create_order",
|
||||
{
|
||||
"course_id" : course_id,
|
||||
"contribution": contribution
|
||||
"contribution": contribution,
|
||||
"face_image" : $("#face_image")[0].src,
|
||||
"photo_id_image" : $("#photo_id_image")[0].src
|
||||
},
|
||||
function(data) {
|
||||
for (prop in data) {
|
||||
@@ -47,18 +54,20 @@ var submitToPaymentProcessing = function() {
|
||||
});
|
||||
}
|
||||
|
||||
function doResetButton(resetButton, captureButton, approveButton, nextButton) {
|
||||
function doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink) {
|
||||
approveButton.removeClass('approved');
|
||||
nextButton.addClass('disabled');
|
||||
nextButtonNav.addClass('is-not-ready');
|
||||
nextLink.attr('href', "#");
|
||||
|
||||
captureButton.show();
|
||||
resetButton.hide();
|
||||
approveButton.hide();
|
||||
}
|
||||
|
||||
function doApproveButton(approveButton, nextButton) {
|
||||
function doApproveButton(approveButton, nextButtonNav, nextLink) {
|
||||
nextButtonNav.removeClass('is-not-ready');
|
||||
approveButton.addClass('approved');
|
||||
nextButton.removeClass('disabled');
|
||||
nextLink.attr('href', "#next");
|
||||
}
|
||||
|
||||
function doSnapshotButton(captureButton, resetButton, approveButton) {
|
||||
@@ -67,9 +76,10 @@ function doSnapshotButton(captureButton, resetButton, approveButton) {
|
||||
approveButton.show();
|
||||
}
|
||||
|
||||
|
||||
function submitNameChange(event) {
|
||||
event.preventDefault();
|
||||
$("#lean_overlay").fadeOut(200);
|
||||
$("#edit-name").css({ 'display' : 'none' });
|
||||
var full_name = $('input[name="name"]').val();
|
||||
var xhr = $.post(
|
||||
"/change_name",
|
||||
@@ -84,7 +94,7 @@ function submitNameChange(event) {
|
||||
.fail(function(jqXhr,text_status, error_thrown) {
|
||||
$('.message-copy').html(jqXhr.responseText);
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
function initSnapshotHandler(names, hasHtml5CameraSupport) {
|
||||
@@ -99,13 +109,15 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
|
||||
var captureButton = $("#" + name + "_capture_button");
|
||||
var resetButton = $("#" + name + "_reset_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 ctx = null;
|
||||
if (hasHtml5CameraSupport) {
|
||||
ctx = canvas[0].getContext('2d');
|
||||
}
|
||||
|
||||
var localMediaStream = null;
|
||||
|
||||
function snapshot(event) {
|
||||
@@ -120,7 +132,12 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
|
||||
video[0].pause();
|
||||
}
|
||||
else {
|
||||
image[0].src = flashCapture[0].snap();
|
||||
if (flashCapture[0].cameraAuthorized()) {
|
||||
image[0].src = flashCapture[0].snap();
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
doSnapshotButton(captureButton, resetButton, approveButton);
|
||||
@@ -137,12 +154,12 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
|
||||
flashCapture[0].reset();
|
||||
}
|
||||
|
||||
doResetButton(resetButton, captureButton, approveButton, nextButton);
|
||||
doResetButton(resetButton, captureButton, approveButton, nextButtonNav, nextLink);
|
||||
return false;
|
||||
}
|
||||
|
||||
function approve() {
|
||||
doApproveButton(approveButton, nextButton)
|
||||
doApproveButton(approveButton, nextButtonNav, nextLink)
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -150,7 +167,8 @@ function initSnapshotHandler(names, hasHtml5CameraSupport) {
|
||||
captureButton.show();
|
||||
resetButton.hide();
|
||||
approveButton.hide();
|
||||
nextButton.addClass('disabled');
|
||||
nextButtonNav.addClass('is-not-ready');
|
||||
nextLink.attr('href', "#");
|
||||
|
||||
// Connect event handlers...
|
||||
video.click(snapshot);
|
||||
@@ -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) {
|
||||
return '<object type="application/x-shockwave-flash" id="' +
|
||||
name + '" name="' + name + '" data=' +
|
||||
'"/static/js/verify_student/CameraCapture.swf"' +
|
||||
'width="500" height="375"><param name="quality" ' +
|
||||
'value="high"><param name="allowscriptaccess" ' +
|
||||
'value="sameDomain"></object>';
|
||||
// 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="' +
|
||||
name + '" name="' + name + '" data=' +
|
||||
'"/static/js/verify_student/CameraCapture.swf?v=3"' +
|
||||
'width="500" height="375"><param name="quality" ' +
|
||||
'value="high"><param name="allowscriptaccess" ' +
|
||||
'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() {
|
||||
$(".carousel-nav").addClass('sr');
|
||||
$("#pay_button").click(submitToPaymentProcessing);
|
||||
$("#pay_button").click(function(){
|
||||
analytics.pageview("Payment Form");
|
||||
submitToPaymentProcessing();
|
||||
});
|
||||
|
||||
// prevent browsers from keeping this button checked
|
||||
$("#confirm_pics_good").prop("checked", false)
|
||||
$("#confirm_pics_good").change(function() {
|
||||
@@ -199,11 +258,13 @@ $(document).ready(function() {
|
||||
|
||||
// add in handlers to add/remove the correct classes to the body
|
||||
// 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')
|
||||
})
|
||||
|
||||
$('#photo_id_next_button').click(function(){
|
||||
$('#photo_id_next_link').click(function(){
|
||||
analytics.pageview("Review Photos");
|
||||
$('body').addClass('step-review').removeClass('step-photos-id')
|
||||
})
|
||||
|
||||
@@ -217,8 +278,19 @@ $(document).ready(function() {
|
||||
if (!hasHtml5CameraSupport) {
|
||||
$("#face_capture_div").html(objectTagForFlashCamera("face_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);
|
||||
|
||||
$('a[rel="external"]').attr('title', gettext('This link will open in a new browser window/tab')).bind('click', linkNewWindow);
|
||||
|
||||
});
|
||||
|
||||
@@ -176,6 +176,9 @@
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
:hover {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
@@ -218,10 +218,15 @@
|
||||
|
||||
|
||||
// reset: forms
|
||||
input {
|
||||
input,textarea {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
margin-right: ($baseline/5);
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: ($baseline/2);
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -464,15 +469,11 @@
|
||||
@include clearfix();
|
||||
width: flex-grid(12,12);
|
||||
|
||||
.wrapper-sts, .sts-track {
|
||||
.sts-course, .sts-track {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wrapper-sts {
|
||||
width: flex-grid(9,12);
|
||||
}
|
||||
|
||||
.sts-track {
|
||||
width: flex-grid(3,12);
|
||||
text-align: right;
|
||||
@@ -490,19 +491,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sts {
|
||||
.sts-label {
|
||||
@extend .t-title7;
|
||||
display: block;
|
||||
margin-bottom: ($baseline/2);
|
||||
border-bottom: ($baseline/10) solid $m-gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
color: $m-gray;
|
||||
}
|
||||
|
||||
.sts-course {
|
||||
@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 line-height(28);
|
||||
@extend .t-weight4;
|
||||
display: block;
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +698,7 @@
|
||||
// help - general list
|
||||
.list-help {
|
||||
margin-top: ($baseline/2);
|
||||
color: $black;
|
||||
|
||||
.help-item {
|
||||
margin-bottom: ($baseline/4);
|
||||
@@ -865,6 +884,7 @@
|
||||
}
|
||||
|
||||
.help-tips {
|
||||
margin-left: $baseline;
|
||||
|
||||
.title {
|
||||
@extend .hd-lv5;
|
||||
@@ -876,6 +896,7 @@
|
||||
|
||||
// help - general list
|
||||
.list-tips {
|
||||
color: $black;
|
||||
|
||||
.tip {
|
||||
margin-bottom: ($baseline/4);
|
||||
@@ -1496,7 +1517,7 @@
|
||||
border-color: $m-pink-l3;
|
||||
|
||||
.title {
|
||||
@extend .t-title4;
|
||||
@extend .t-title5;
|
||||
@extend .t-weight4;
|
||||
border-bottom-color: $m-pink-l3;
|
||||
background: tint($m-pink, 95%);
|
||||
@@ -1615,6 +1636,27 @@
|
||||
|
||||
// VIEW: review photos
|
||||
&.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 {
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
${_("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)}
|
||||
|
||||
@@ -11,9 +11,9 @@ ${_("The items in your order are:")}
|
||||
|
||||
${_("Quantity - Description - Price")}
|
||||
%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
|
||||
${_("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:
|
||||
${order_item.additional_instruction_text}
|
||||
|
||||
@@ -9,6 +9,4 @@
|
||||
<section class="container">
|
||||
<p><h1>${_("There was an error processing your order!")}</h1></p>
|
||||
${error_html}
|
||||
|
||||
<p><a href="${reverse('shoppingcart.views.show_cart')}">${_("Return to cart to retry payment")}</a></p>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from student.views import course_from_id %>
|
||||
<%! from datetime import datetime %>
|
||||
<%! import pytz %>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
<%block name="bodyclass">register verification-process step-confirmation</%block>
|
||||
@@ -15,28 +13,31 @@
|
||||
${notification}
|
||||
</section>
|
||||
% endif
|
||||
<% course_id = order_items[0].course_id %>
|
||||
<% course = course_from_id(course_id) %>
|
||||
|
||||
<div class="container">
|
||||
<section class="wrapper cart-list">
|
||||
|
||||
<header class="page-header">
|
||||
<h2 class="title">
|
||||
<span class="wrapper-sts">
|
||||
<span class="sts">${_("You are now registered for")}</span>
|
||||
<span class="sts-course">${course.display_name}</span>
|
||||
</span>
|
||||
<span class="sts-track">
|
||||
<span class="sts-track-value">
|
||||
<span class="context">${_("Registered as: ")}</span> ${_("ID Verified")}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
<h2 class="title">
|
||||
<span class="sts-label">${_("You are now registered for: ")}</span>
|
||||
|
||||
<span class="wrapper-sts">
|
||||
<span class="sts-course">
|
||||
<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>
|
||||
|
||||
<div class="wrapper-progress">
|
||||
<span class="sts-track">
|
||||
<span class="sts-track-value">
|
||||
<span class="context">${_("Registering as: ")}</span> ${_("ID Verified")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="wrapper-progress">
|
||||
<section class="progress">
|
||||
<h3 class="sr title">${_("Your Progress")}</h3>
|
||||
|
||||
@@ -108,11 +109,11 @@
|
||||
<tr>
|
||||
<td>${item.line_desc}</td>
|
||||
<td>
|
||||
${_("Starts: {start_date}").format(start_date=course.start_date_text)}
|
||||
${_("Starts: {start_date}").format(start_date=course_start_date_text)}
|
||||
</td>
|
||||
<td class="options">
|
||||
%if course.start > datetime.today().replace(tzinfo=pytz.utc):
|
||||
${_("Starts: {start_date}").format(start_date=course.start_date_text)}
|
||||
%if course_has_started:
|
||||
${_("Starts: {start_date}").format(start_date=course_start_date_text)}
|
||||
%else:
|
||||
<a class="action action-course" href="${reverse('course_root', kwargs={'course_id': item.course_id})}">${_("Go to Course")}</a>
|
||||
%endif
|
||||
@@ -198,8 +199,15 @@
|
||||
</div>
|
||||
% endif
|
||||
</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>
|
||||
|
||||
<%doc>
|
||||
<li class="info-item billing-info">
|
||||
<h4 class="title">${_("Billing Information")}</h4>
|
||||
|
||||
@@ -249,6 +257,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
</%doc>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<section id="edit-name" class="modal">
|
||||
<header>
|
||||
<h4>${_("Edit Your Full Name")}</h4>
|
||||
</header>
|
||||
<form id="course-checklists" class="course-checklists" method="post" action="">
|
||||
<div role="alert" class="status message submission-error" tabindex="-1">
|
||||
<p class="message-title">${_("The following error occured while editing your name:")}
|
||||
<span class="message-copy"> </span>
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
<label for="name">${_('Full Name')}</label>
|
||||
<input id="name" type="text" name="name" value="" placeholder="${user_full_name}" required aria-required="true" />
|
||||
</p>
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>${_("Edit Your Name")}</h2>
|
||||
<hr />
|
||||
</header>
|
||||
<div id="change_name_body">
|
||||
<form id="course-checklists" class="course-checklists" method="post" action="">
|
||||
<div role="alert" class="status message submission-error" tabindex="-1">
|
||||
<p class="message-title">${_("The following error occured while editing your name:")}
|
||||
<span class="message-copy"> </span>
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<input id="name" type="text" name="name" value="" placeholder="${user_full_name}" required aria-required="true" />
|
||||
<label>${_("Reason for name change:")}</label>
|
||||
<textarea id="name_rationale_field" value=""></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action action-primary action-save">${_("Save")}</button>
|
||||
</div>
|
||||
</form>
|
||||
<a href="#" data-dismiss="leanModal" rel="view" class="action action-close action-editname-close">
|
||||
<i class="icon-remove-sign"></i>
|
||||
<span class="label">${_("close")}</span>
|
||||
</a>
|
||||
<div class="actions">
|
||||
<button id="submit" class="action action-primary action-save">${_("Change my name")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
<span class="sr">${_("close")}</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
<header class="page-header">
|
||||
<h2 class="title">
|
||||
<span class="sts-label">${_("You are registering for")}</span>
|
||||
|
||||
<span class="wrapper-sts">
|
||||
<span class="sts">${_("You are registering for")}</span>
|
||||
<span class="sts-course">${course_name}</span>
|
||||
</span>
|
||||
<span class="sts-track">
|
||||
<span class="sts-track-value">
|
||||
<span class="context">${_("Registering as: ")}</span> ${_("ID Verified")}
|
||||
<span class="sts-course">
|
||||
<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 class="sts-track">
|
||||
<span class="sts-track-value">
|
||||
<span class="context">${_("Registering as: ")}</span> ${_("ID Verified")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
@@ -13,6 +13,32 @@
|
||||
</%block>
|
||||
|
||||
<%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">
|
||||
<section class="wrapper">
|
||||
|
||||
@@ -79,7 +105,7 @@
|
||||
<div class="placeholder-cam" id="face_capture_div">
|
||||
|
||||
<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>
|
||||
|
||||
<video id="face_video" autoplay></video><br/>
|
||||
@@ -133,18 +159,20 @@
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<ol class="wizard-steps">
|
||||
<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>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -164,7 +192,7 @@
|
||||
<div class="placeholder-cam" id="photo_id_capture_div">
|
||||
|
||||
<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>
|
||||
|
||||
<video id="photo_id_video" autoplay></video><br/>
|
||||
@@ -226,12 +254,12 @@
|
||||
</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>
|
||||
|
||||
<ol class="wizard-steps">
|
||||
<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>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -247,19 +275,6 @@
|
||||
|
||||
<div class="wrapper-task">
|
||||
<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">
|
||||
<h4 class="title">${_("Review the Photos You've Taken")}</h4>
|
||||
@@ -313,6 +328,20 @@
|
||||
</div>
|
||||
</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">
|
||||
<h4 class="title">${_("Check Your Contribution Level")}</h4>
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<div class="copy">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user