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):
@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()
......@@ -383,8 +392,21 @@ class CertificateItem(OrderItem):
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)
from ratelimitbackend import admin
from verify_student.models import SoftwareSecurePhotoVerification
admin.site.register(SoftwareSecurePhotoVerification)
......@@ -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")
......@@ -30,9 +30,16 @@ urlpatterns = patterns(
),
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;
}
}
}
}
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 {
......
<%! 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>
<%! 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>
<div class="wrapper-progress">
<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>
<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>
......
<%! 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>
......
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